feat: create ClickUp task after confirming accommodation booking
This commit is contained in:
@@ -61,6 +61,13 @@ ACCOMMODATION_TERMS_IT=
|
|||||||
BPN_CONNECT_BASE_URL=https://bpn-connect.ep-reisen.app
|
BPN_CONNECT_BASE_URL=https://bpn-connect.ep-reisen.app
|
||||||
BPN_CONNECT_API_KEY=
|
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_USER=
|
||||||
APP_BPN_PASSWORD=
|
APP_BPN_PASSWORD=
|
||||||
APP_BPN_IP=
|
APP_BPN_IP=
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ framework:
|
|||||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
Symfony\Component\Notifier\Message\ChatMessage: async
|
||||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||||
App\Message\MailjetNewsletterEventMessage: async
|
App\Message\MailjetNewsletterEventMessage: async
|
||||||
|
App\Message\CreateClickUpBookingTaskMessage: async
|
||||||
|
|
||||||
# Route your messages to the transports
|
# Route your messages to the transports
|
||||||
# 'App\Message\YourMessage': async
|
# 'App\Message\YourMessage': async
|
||||||
|
|||||||
@@ -329,7 +329,14 @@ services:
|
|||||||
App\BpnConnect\ContingentsClient:
|
App\BpnConnect\ContingentsClient:
|
||||||
parent: App\BpnConnect\AbstractApiClient
|
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:
|
arguments:
|
||||||
$apiKey: '%env(default::MAILJET_API_KEY)%'
|
$apiKey: '%env(default::MAILJET_API_KEY)%'
|
||||||
$apiSecret: '%env(default::MAILJET_API_SECRET)%'
|
$apiSecret: '%env(default::MAILJET_API_SECRET)%'
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ClickUp;
|
||||||
|
|
||||||
|
use App\ClickUp\Exception\ClickUpException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
class ApiClient
|
||||||
|
{
|
||||||
|
private const string DEFAULT_BASE_URL = 'https://api.clickup.com/api/v2';
|
||||||
|
|
||||||
|
// Creating a task from a template regularly takes several seconds on ClickUp's side, so these
|
||||||
|
// ceilings sit well above the ones the BpnConnect client uses. They exist to stop a hung
|
||||||
|
// upstream from pinning a worker forever, not to enforce a latency budget.
|
||||||
|
private const int IDLE_TIMEOUT_SECONDS = 30;
|
||||||
|
private const int MAX_DURATION_SECONDS = 120;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly HttpClientInterface $httpClient,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly ?string $apiToken = null,
|
||||||
|
private readonly ?string $listId = null,
|
||||||
|
private readonly ?string $templateId = null,
|
||||||
|
private readonly ?string $baseUrl = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environments without ClickUp credentials (dev, test, staging) must not fail — callers check
|
||||||
|
* this instead of letting assertConfigured() blow up.
|
||||||
|
*/
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return null !== $this->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<string, mixed> $payload
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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).');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ClickUp\Exception;
|
||||||
|
|
||||||
|
class ClickUpException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\Exception\NewsletterProviderException;
|
use App\Exception\NewsletterProviderException;
|
||||||
use App\Service\MailjetApiClient;
|
use App\Mailjet\ApiClient;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
@@ -31,7 +31,7 @@ final class MailjetNewsletterWebhookCommand extends Command
|
|||||||
private const int WEBHOOK_VERSION = 2;
|
private const int WEBHOOK_VERSION = 2;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MailjetApiClient $mailjetApiClient,
|
private readonly ApiClient $mailjetApiClient,
|
||||||
#[Autowire(env: 'APP_BASE_URL')]
|
#[Autowire(env: 'APP_BASE_URL')]
|
||||||
private readonly string $defaultBaseUrl,
|
private readonly string $defaultBaseUrl,
|
||||||
#[Autowire(env: 'MAILJET_WEBHOOK_BASIC_PASSWORD')]
|
#[Autowire(env: 'MAILJET_WEBHOOK_BASIC_PASSWORD')]
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ namespace App\Controller\Admin\AccommodationBooking;
|
|||||||
|
|
||||||
use App\Entity\Groups\AccommodationBooking;
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
use App\Htmx\HxRedirectResponse;
|
use App\Htmx\HxRedirectResponse;
|
||||||
|
use App\Message\CreateClickUpBookingTaskMessage;
|
||||||
use App\Service\AccommodationBookingService;
|
use App\Service\AccommodationBookingService;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ class ConfirmController extends AbstractController
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AccommodationBookingService $bookingService,
|
private readonly AccommodationBookingService $bookingService,
|
||||||
|
private readonly MessageBusInterface $messageBus,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -53,6 +56,9 @@ class ConfirmController extends AbstractController
|
|||||||
'id' => $booking->getId(),
|
'id' => $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()]));
|
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Service;
|
namespace App\Mailjet;
|
||||||
|
|
||||||
use App\Exception\NewsletterProviderException;
|
use App\Exception\NewsletterProviderException;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
class MailjetApiClient
|
class ApiClient
|
||||||
{
|
{
|
||||||
private const string DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST';
|
private const string DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST';
|
||||||
private const int NAME_MAX_LENGTH = 255;
|
private const int NAME_MAX_LENGTH = 255;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Message;
|
||||||
|
|
||||||
|
final class CreateClickUpBookingTaskMessage
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $bookingId,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\MessageHandler;
|
||||||
|
|
||||||
|
use App\ClickUp\ApiClient;
|
||||||
|
use App\ClickUp\Exception\ClickUpException;
|
||||||
|
use App\Message\CreateClickUpBookingTaskMessage;
|
||||||
|
use App\Repository\Groups\AccommodationBookingRepository;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
#[AsMessageHandler]
|
||||||
|
final class CreateClickUpBookingTaskHandler
|
||||||
|
{
|
||||||
|
/** The status every freshly created group-operations task starts in. */
|
||||||
|
public const string TASK_STATUS = 'GRO';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationBookingRepository $bookingRepository,
|
||||||
|
private readonly ApiClient $clickUpClient,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(CreateClickUpBookingTaskMessage $message): void
|
||||||
|
{
|
||||||
|
if (!$this->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use App\Entity\NewsletterConsent;
|
|||||||
use App\Entity\NewsletterOptInRequest;
|
use App\Entity\NewsletterOptInRequest;
|
||||||
use App\Exception\NewsletterListNotAllowedException;
|
use App\Exception\NewsletterListNotAllowedException;
|
||||||
use App\Exception\NewsletterProviderException;
|
use App\Exception\NewsletterProviderException;
|
||||||
|
use App\Mailjet\ApiClient;
|
||||||
use App\Model\NewsletterConfirmationResult;
|
use App\Model\NewsletterConfirmationResult;
|
||||||
use App\Model\NewsletterSubscriptionRequestResult;
|
use App\Model\NewsletterSubscriptionRequestResult;
|
||||||
use App\Repository\NewsletterConsentRepository;
|
use App\Repository\NewsletterConsentRepository;
|
||||||
@@ -27,7 +28,7 @@ class NewsletterManager
|
|||||||
private readonly NewsletterOptInRequestRepository $optInRequestRepository,
|
private readonly NewsletterOptInRequestRepository $optInRequestRepository,
|
||||||
private readonly NewsletterConsentRepository $consentRepository,
|
private readonly NewsletterConsentRepository $consentRepository,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly MailjetApiClient $newsletterService,
|
private readonly ApiClient $newsletterService,
|
||||||
private readonly Mailer $mailer,
|
private readonly Mailer $mailer,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
private readonly int $newsletterConfirmationTtlHours,
|
private readonly int $newsletterConfirmationTtlHours,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\ClickUp;
|
||||||
|
|
||||||
|
use App\ClickUp\ApiClient;
|
||||||
|
use App\ClickUp\Exception\ClickUpException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
|
|
||||||
|
class ApiClientTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testCreateTaskFromTemplatePostsTheNameAndReturnsTheTaskId(): 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'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Command;
|
namespace App\Tests\Command;
|
||||||
|
|
||||||
use App\Command\MailjetNewsletterWebhookCommand;
|
use App\Command\MailjetNewsletterWebhookCommand;
|
||||||
use App\Service\MailjetApiClient;
|
use App\Mailjet\ApiClient;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
@@ -15,7 +15,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase
|
|||||||
{
|
{
|
||||||
public function testRegisterCreatesWebhookAndPrintsPayload(): void
|
public function testRegisterCreatesWebhookAndPrintsPayload(): void
|
||||||
{
|
{
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailjet->expects(self::once())
|
$mailjet->expects(self::once())
|
||||||
->method('createEventCallbackUrl')
|
->method('createEventCallbackUrl')
|
||||||
->with(
|
->with(
|
||||||
@@ -43,7 +43,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase
|
|||||||
|
|
||||||
public function testRemoveDeletesWebhookById(): void
|
public function testRemoveDeletesWebhookById(): void
|
||||||
{
|
{
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailjet->expects(self::never())->method('createEventCallbackUrl');
|
$mailjet->expects(self::never())->method('createEventCallbackUrl');
|
||||||
$mailjet->expects(self::once())
|
$mailjet->expects(self::once())
|
||||||
->method('deleteEventCallbackUrl')
|
->method('deleteEventCallbackUrl')
|
||||||
@@ -58,7 +58,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase
|
|||||||
|
|
||||||
public function testDeactivateUsesRemoveFlow(): void
|
public function testDeactivateUsesRemoveFlow(): void
|
||||||
{
|
{
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailjet->expects(self::never())->method('createEventCallbackUrl');
|
$mailjet->expects(self::never())->method('createEventCallbackUrl');
|
||||||
$mailjet->expects(self::once())
|
$mailjet->expects(self::once())
|
||||||
->method('deleteEventCallbackUrl')
|
->method('deleteEventCallbackUrl')
|
||||||
@@ -73,7 +73,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase
|
|||||||
|
|
||||||
public function testRemoveFailsWithoutCallbackId(): void
|
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('createEventCallbackUrl');
|
||||||
$mailjet->expects(self::never())->method('deleteEventCallbackUrl');
|
$mailjet->expects(self::never())->method('deleteEventCallbackUrl');
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase
|
|||||||
self::assertStringContainsString('A numeric callback ID is required', $tester->getDisplay());
|
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(
|
return new MailjetNewsletterWebhookCommand(
|
||||||
$mailjetApiClient,
|
$mailjetApiClient,
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ namespace App\Tests\Controller\Admin\AccommodationBooking;
|
|||||||
use App\Controller\Admin\AccommodationBooking\ConfirmController;
|
use App\Controller\Admin\AccommodationBooking\ConfirmController;
|
||||||
use App\Entity\Groups\AccommodationBooking;
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
use App\Enum\Groups\AccommodationBookingStatus;
|
use App\Enum\Groups\AccommodationBookingStatus;
|
||||||
|
use App\Message\CreateClickUpBookingTaskMessage;
|
||||||
use App\Service\AccommodationBookingService;
|
use App\Service\AccommodationBookingService;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +29,7 @@ class ConfirmControllerTest extends TestCase
|
|||||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
$bookingService->expects(self::never())->method('confirmBooking');
|
$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'));
|
$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 = $this->createMock(AccommodationBookingService::class);
|
||||||
$bookingService->expects(self::once())->method('confirmBooking')->with($booking);
|
$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'));
|
$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 = $this->createMock(AccommodationBookingService::class);
|
||||||
$bookingService->expects(self::never())->method('confirmBooking');
|
$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);
|
$this->expectException(AccessDeniedException::class);
|
||||||
|
|
||||||
@@ -70,7 +81,7 @@ class ConfirmControllerTest extends TestCase
|
|||||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||||
$bookingService->expects(self::never())->method('confirmBooking');
|
$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'));
|
$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 = $this->createMock(AccommodationBookingService::class);
|
||||||
$bookingService->expects(self::never())->method('confirmBooking');
|
$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'));
|
$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'));
|
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
|
private function receivedBooking(): AccommodationBooking
|
||||||
{
|
{
|
||||||
$booking = new AccommodationBooking();
|
$booking = new AccommodationBooking();
|
||||||
@@ -125,10 +147,11 @@ final class TestableConfirmController extends ConfirmController
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
AccommodationBookingService $bookingService,
|
AccommodationBookingService $bookingService,
|
||||||
|
MessageBusInterface $messageBus,
|
||||||
LoggerInterface $logger,
|
LoggerInterface $logger,
|
||||||
private readonly bool $tokenValid = true,
|
private readonly bool $tokenValid = true,
|
||||||
) {
|
) {
|
||||||
parent::__construct($bookingService, $logger);
|
parent::__construct($bookingService, $messageBus, $logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
|
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
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 PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
use Symfony\Component\HttpClient\MockHttpClient;
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
|
|
||||||
class MailjetApiClientTest extends TestCase
|
class ApiClientTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testChecksSubscriptionAgainstSuppliedListId(): void
|
public function testChecksSubscriptionAgainstSuppliedListId(): void
|
||||||
{
|
{
|
||||||
@@ -37,7 +37,7 @@ class MailjetApiClientTest extends TestCase
|
|||||||
], JSON_THROW_ON_ERROR));
|
], 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(' [email protected] ', 123);
|
$subscribed = $mailjet->isSubscribed(' [email protected] ', 123);
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ class MailjetApiClientTest extends TestCase
|
|||||||
], JSON_THROW_ON_ERROR));
|
], JSON_THROW_ON_ERROR));
|
||||||
});
|
});
|
||||||
|
|
||||||
$mailjet = new MailjetApiClient(
|
$mailjet = new ApiClient(
|
||||||
$client,
|
$client,
|
||||||
new NullLogger(),
|
new NullLogger(),
|
||||||
'key',
|
'key',
|
||||||
@@ -134,7 +134,7 @@ class MailjetApiClientTest extends TestCase
|
|||||||
return new MockResponse(json_encode(['Data' => []], JSON_THROW_ON_ERROR));
|
return new MockResponse(json_encode(['Data' => []], JSON_THROW_ON_ERROR));
|
||||||
});
|
});
|
||||||
|
|
||||||
$mailjet = new MailjetApiClient(
|
$mailjet = new ApiClient(
|
||||||
$client,
|
$client,
|
||||||
new NullLogger(),
|
new NullLogger(),
|
||||||
'key',
|
'key',
|
||||||
@@ -166,7 +166,7 @@ class MailjetApiClientTest extends TestCase
|
|||||||
return new MockResponse(json_encode(['Data' => []], JSON_THROW_ON_ERROR));
|
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(' [email protected] ');
|
$mailjet->upsertContact(' [email protected] ');
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ class MailjetApiClientTest extends TestCase
|
|||||||
return new MockResponse(json_encode(['Data' => []], JSON_THROW_ON_ERROR));
|
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(' [email protected] ', 456);
|
$mailjet->ensureSubscribed(' [email protected] ', 456);
|
||||||
|
|
||||||
+4
-4
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
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 PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
use Symfony\Component\HttpClient\MockHttpClient;
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
@@ -33,7 +33,7 @@ class MailjetNewsletterWebhookClientTest extends TestCase
|
|||||||
], JSON_THROW_ON_ERROR));
|
], 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');
|
$response = $mailjet->createEventCallbackUrl('unsub', 'https://my.ep-reisen.de/webhooks/mailjet/newsletter');
|
||||||
|
|
||||||
self::assertSame(456, $response['Data'][0]['ID']);
|
self::assertSame(456, $response['Data'][0]['ID']);
|
||||||
@@ -48,7 +48,7 @@ class MailjetNewsletterWebhookClientTest extends TestCase
|
|||||||
return new MockResponse('', ['http_code' => 204]);
|
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);
|
$mailjet->deleteEventCallbackUrl(456);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\MessageHandler;
|
||||||
|
|
||||||
|
use App\ClickUp\ApiClient;
|
||||||
|
use App\ClickUp\Exception\ClickUpException;
|
||||||
|
use App\Entity\Groups\Accommodation;
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Message\CreateClickUpBookingTaskMessage;
|
||||||
|
use App\MessageHandler\CreateClickUpBookingTaskHandler;
|
||||||
|
use App\Repository\Groups\AccommodationBookingRepository;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
class CreateClickUpBookingTaskHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testCreatesTheTaskAndMovesItIntoTheGroupsStatus(): void
|
||||||
|
{
|
||||||
|
$repository = $this->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<string, array{callable(AccommodationBooking): void}>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,11 @@ use App\Email\Mailer;
|
|||||||
use App\Entity\NewsletterConsent;
|
use App\Entity\NewsletterConsent;
|
||||||
use App\Entity\NewsletterOptInRequest;
|
use App\Entity\NewsletterOptInRequest;
|
||||||
use App\Exception\NewsletterListNotAllowedException;
|
use App\Exception\NewsletterListNotAllowedException;
|
||||||
|
use App\Mailjet\ApiClient;
|
||||||
use App\Model\NewsletterConfirmationResult;
|
use App\Model\NewsletterConfirmationResult;
|
||||||
use App\Model\NewsletterSubscriptionRequestResult;
|
use App\Model\NewsletterSubscriptionRequestResult;
|
||||||
use App\Repository\NewsletterConsentRepository;
|
use App\Repository\NewsletterConsentRepository;
|
||||||
use App\Repository\NewsletterOptInRequestRepository;
|
use App\Repository\NewsletterOptInRequestRepository;
|
||||||
use App\Service\MailjetApiClient;
|
|
||||||
use App\Service\NewsletterManager;
|
use App\Service\NewsletterManager;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
@@ -26,7 +26,7 @@ class NewsletterManagerTest extends TestCase
|
|||||||
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
||||||
$consents = $this->createMock(NewsletterConsentRepository::class);
|
$consents = $this->createMock(NewsletterConsentRepository::class);
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
|
||||||
$mailjet->expects(self::never())->method('isSubscribed');
|
$mailjet->expects(self::never())->method('isSubscribed');
|
||||||
@@ -83,7 +83,7 @@ class NewsletterManagerTest extends TestCase
|
|||||||
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
||||||
$consents = $this->createMock(NewsletterConsentRepository::class);
|
$consents = $this->createMock(NewsletterConsentRepository::class);
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
|
||||||
$mailjet->expects(self::never())->method('isSubscribed');
|
$mailjet->expects(self::never())->method('isSubscribed');
|
||||||
@@ -114,7 +114,7 @@ class NewsletterManagerTest extends TestCase
|
|||||||
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
||||||
$consents = $this->createMock(NewsletterConsentRepository::class);
|
$consents = $this->createMock(NewsletterConsentRepository::class);
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
|
||||||
$mailjet->expects(self::never())->method('isSubscribed');
|
$mailjet->expects(self::never())->method('isSubscribed');
|
||||||
@@ -147,7 +147,7 @@ class NewsletterManagerTest extends TestCase
|
|||||||
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
||||||
$consents = $this->createMock(NewsletterConsentRepository::class);
|
$consents = $this->createMock(NewsletterConsentRepository::class);
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
|
||||||
$mailjet->expects(self::never())->method('isSubscribed');
|
$mailjet->expects(self::never())->method('isSubscribed');
|
||||||
@@ -191,7 +191,7 @@ class NewsletterManagerTest extends TestCase
|
|||||||
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
||||||
$consents = $this->createMock(NewsletterConsentRepository::class);
|
$consents = $this->createMock(NewsletterConsentRepository::class);
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
|
||||||
$mailjet->expects(self::never())->method('isSubscribed');
|
$mailjet->expects(self::never())->method('isSubscribed');
|
||||||
@@ -219,7 +219,7 @@ class NewsletterManagerTest extends TestCase
|
|||||||
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
||||||
$consents = $this->createMock(NewsletterConsentRepository::class);
|
$consents = $this->createMock(NewsletterConsentRepository::class);
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
|
||||||
$mailjet->expects(self::never())->method('isSubscribed');
|
$mailjet->expects(self::never())->method('isSubscribed');
|
||||||
@@ -251,7 +251,7 @@ class NewsletterManagerTest extends TestCase
|
|||||||
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
||||||
$consents = $this->createMock(NewsletterConsentRepository::class);
|
$consents = $this->createMock(NewsletterConsentRepository::class);
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
|
||||||
$mailjet->expects(self::never())->method('isSubscribed');
|
$mailjet->expects(self::never())->method('isSubscribed');
|
||||||
@@ -276,7 +276,7 @@ class NewsletterManagerTest extends TestCase
|
|||||||
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
||||||
$consents = $this->createMock(NewsletterConsentRepository::class);
|
$consents = $this->createMock(NewsletterConsentRepository::class);
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
|
||||||
$mailjet->expects(self::never())->method('ensureSubscribed');
|
$mailjet->expects(self::never())->method('ensureSubscribed');
|
||||||
@@ -303,7 +303,7 @@ class NewsletterManagerTest extends TestCase
|
|||||||
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
||||||
$consents = $this->createMock(NewsletterConsentRepository::class);
|
$consents = $this->createMock(NewsletterConsentRepository::class);
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
|
||||||
$mailjet->expects(self::never())->method('ensureSubscribed');
|
$mailjet->expects(self::never())->method('ensureSubscribed');
|
||||||
@@ -339,7 +339,7 @@ class NewsletterManagerTest extends TestCase
|
|||||||
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
||||||
$consents = $this->createMock(NewsletterConsentRepository::class);
|
$consents = $this->createMock(NewsletterConsentRepository::class);
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
|
||||||
$repository->expects(self::once())->method('findByTokenHash')->with(hash('sha256', $token))->willReturn($confirmation);
|
$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);
|
$repository = $this->createMock(NewsletterOptInRequestRepository::class);
|
||||||
$consents = $this->createMock(NewsletterConsentRepository::class);
|
$consents = $this->createMock(NewsletterConsentRepository::class);
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$mailjet = $this->createMock(MailjetApiClient::class);
|
$mailjet = $this->createMock(ApiClient::class);
|
||||||
$mailer = $this->createMock(Mailer::class);
|
$mailer = $this->createMock(Mailer::class);
|
||||||
|
|
||||||
$repository->expects(self::once())->method('findByTokenHash')->with(hash('sha256', $token))->willReturn($confirmation);
|
$repository->expects(self::once())->method('findByTokenHash')->with(hash('sha256', $token))->willReturn($confirmation);
|
||||||
@@ -436,7 +436,7 @@ class NewsletterManagerTest extends TestCase
|
|||||||
* @param MockObject&NewsletterOptInRequestRepository $repository
|
* @param MockObject&NewsletterOptInRequestRepository $repository
|
||||||
* @param MockObject&NewsletterConsentRepository $consents
|
* @param MockObject&NewsletterConsentRepository $consents
|
||||||
* @param MockObject&EntityManagerInterface $entityManager
|
* @param MockObject&EntityManagerInterface $entityManager
|
||||||
* @param MockObject&MailjetApiClient $mailjet
|
* @param MockObject&ApiClient $mailjet
|
||||||
* @param MockObject&Mailer $mailer
|
* @param MockObject&Mailer $mailer
|
||||||
* @param array<int, string> $mailjetLists
|
* @param array<int, string> $mailjetLists
|
||||||
*/
|
*/
|
||||||
@@ -444,7 +444,7 @@ class NewsletterManagerTest extends TestCase
|
|||||||
NewsletterOptInRequestRepository $repository,
|
NewsletterOptInRequestRepository $repository,
|
||||||
NewsletterConsentRepository $consents,
|
NewsletterConsentRepository $consents,
|
||||||
EntityManagerInterface $entityManager,
|
EntityManagerInterface $entityManager,
|
||||||
MailjetApiClient $mailjet,
|
ApiClient $mailjet,
|
||||||
Mailer $mailer,
|
Mailer $mailer,
|
||||||
array $mailjetLists = [
|
array $mailjetLists = [
|
||||||
10321569 => 'E&P Newsletter',
|
10321569 => 'E&P Newsletter',
|
||||||
|
|||||||
Reference in New Issue
Block a user