diff --git a/config/packages/zenstruck_schedule.yaml b/config/packages/zenstruck_schedule.yaml index 06c4840d..6699378a 100644 --- a/config/packages/zenstruck_schedule.yaml +++ b/config/packages/zenstruck_schedule.yaml @@ -29,6 +29,10 @@ zenstruck_schedule: frequency: "45 1 * * *" description: "Removes expired pending newsletter double opt-in requests" + - task: app:cleanup:travel-mailing-lists + frequency: "50 1 * * *" + description: "Deletes Mailjet travel mailing lists and their orphaned contacts 30 days after the travel started" + - task: league:oauth2-server:clear-expired-tokens frequency: "0 * * * *" description: "Removes expired oauth2 tokens hourly" diff --git a/migrations/Version20261104142531.php b/migrations/Version20261104142531.php new file mode 100644 index 00000000..16a27f4c --- /dev/null +++ b/migrations/Version20261104142531.php @@ -0,0 +1,26 @@ +addSql('CREATE TABLE travel_mailing_list (id INT AUTO_INCREMENT NOT NULL, travel_code VARCHAR(32) NOT NULL, mailjet_list_id INT NOT NULL, delete_after DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE travel_mailing_list'); + } +} diff --git a/src/Command/CleanupTravelMailingListsCommand.php b/src/Command/CleanupTravelMailingListsCommand.php new file mode 100644 index 00000000..4adc377c --- /dev/null +++ b/src/Command/CleanupTravelMailingListsCommand.php @@ -0,0 +1,67 @@ +travelMailingListRepository->findDueForDeletion(new \DateTimeImmutable()); + if ([] === $mailingLists) { + $io->success('No travel mailing lists due for deletion.'); + + return Command::SUCCESS; + } + + $failed = 0; + foreach ($mailingLists as $mailingList) { + try { + $deletedContacts = $this->travelMailingListManager->delete($mailingList); + $io->writeln(sprintf('Deleted %s (list %d) and %d orphaned contact(s).', $mailingList->getTravelCode(), $mailingList->getMailjetListId(), $deletedContacts)); + } catch (NewsletterProviderException $exception) { + // The row stays, so the next run picks the list up again. + ++$failed; + $this->logger->error('Delete travel mailing list failed', [ + 'travel_code' => $mailingList->getTravelCode(), + 'mailjet_list_id' => $mailingList->getMailjetListId(), + 'error' => $exception->getMessage(), + ]); + $io->error(sprintf('%s (list %d): %s', $mailingList->getTravelCode(), $mailingList->getMailjetListId(), $exception->getMessage())); + } + } + + if ($failed > 0) { + return Command::FAILURE; + } + + $io->success(sprintf('Deleted %d travel mailing list(s).', count($mailingLists))); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Admin/TravelMailing/CreateListController.php b/src/Controller/Admin/TravelMailing/CreateListController.php new file mode 100644 index 00000000..21611d8f --- /dev/null +++ b/src/Controller/Admin/TravelMailing/CreateListController.php @@ -0,0 +1,94 @@ +isCsrfTokenValid(self::CSRF_TOKEN_ID, $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $travelCode = (new DateCodeUtility())->sanitize($request->request->getString('travelCode')); + $returnUrl = $this->generateUrl('app_admin_travelmailing', ['travelCode' => $travelCode]); + + if (null !== $this->travelMailingListRepository->findOneByTravelCode($travelCode)) { + $this->addFlash('error', sprintf('Für %s gibt es bereits eine Mailjet-Liste.', $travelCode)); + + return $this->redirect($returnUrl); + } + + $travelStartsAt = $this->travelDataProvider->mapDateCodeToStartDate($travelCode); + if (null === $travelStartsAt) { + $this->addFlash('error', sprintf('Für %s ist kein Reisebeginn bekannt, die Liste könnte nicht automatisch gelöscht werden.', $travelCode)); + + return $this->redirect($returnUrl); + } + + // Fetched again rather than posted from the page, so the list holds what BusPro reports now. + try { + $recipients = $this->travelsClient->getMailingRecipients($travelCode)->data; + } catch (BpnConnectException) { + $this->addFlash('error', 'Die Teilnehmerliste konnte nicht von BusPro abgerufen werden. Bitte später erneut versuchen.'); + + return $this->redirect($returnUrl); + } + + if ([] === $recipients) { + $this->addFlash('error', sprintf('Keine Empfänger für %s gefunden.', $travelCode)); + + return $this->redirect($returnUrl); + } + + try { + $mailingList = $this->travelMailingListManager->create($travelCode, $recipients, $travelStartsAt); + } catch (NewsletterProviderException $exception) { + $this->adminLogger->error('Create travel mailing list failed', [ + 'travel_code' => $travelCode, + 'error' => $exception->getMessage(), + ]); + $this->addFlash('error', 'Die Mailjet-Liste konnte nicht angelegt werden: '.$exception->getMessage()); + + return $this->redirect($returnUrl); + } + + $this->adminLogger->info('Create travel mailing list', [ + 'travel_code' => $travelCode, + 'mailjet_list_id' => $mailingList->getMailjetListId(), + 'recipients' => count($recipients), + ]); + $this->addFlash('success', sprintf('Die Mailjet-Liste %s wurde mit %d Empfängern angelegt.', $travelCode, count($recipients))); + + return $this->redirect($returnUrl); + } +} diff --git a/src/Controller/Admin/TravelMailing/IndexController.php b/src/Controller/Admin/TravelMailing/IndexController.php index 34894c80..eec53f3a 100644 --- a/src/Controller/Admin/TravelMailing/IndexController.php +++ b/src/Controller/Admin/TravelMailing/IndexController.php @@ -9,6 +9,7 @@ use App\BpnConnect\TravelsClient; use App\BusProNet\Utility\DateCodeUtility; use App\Form\Admin\TravelMailingLookupType; use App\Form\Model\TravelMailingLookupDto; +use App\Repository\TravelMailingListRepository; use App\Service\TravelDataProvider; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -22,6 +23,7 @@ class IndexController extends AbstractController public function __construct( private readonly TravelsClient $travelsClient, private readonly TravelDataProvider $travelDataProvider, + private readonly TravelMailingListRepository $travelMailingListRepository, ) { } @@ -36,6 +38,8 @@ class IndexController extends AbstractController $result = null; $travelLabel = null; + $travelStartsAt = null; + $mailingList = null; $failed = false; if ($form->isSubmitted() && $form->isValid()) { // The local index matches codes exactly, so "lpjmp-281126" has to become "LPJMP281126" @@ -45,6 +49,8 @@ class IndexController extends AbstractController try { $result = $this->travelsClient->getMailingRecipients($travelCode); $travelLabel = $this->travelDataProvider->mapDateCodeToLabel($travelCode); + $travelStartsAt = $this->travelDataProvider->mapDateCodeToStartDate($travelCode); + $mailingList = $this->travelMailingListRepository->findOneByTravelCode($travelCode); } catch (BpnConnectException) { // The client has already logged the cause; the page only needs to say it failed. $failed = true; @@ -55,6 +61,8 @@ class IndexController extends AbstractController 'form' => $form, 'result' => $result, 'travel_label' => $travelLabel, + 'travel_starts_at' => $travelStartsAt, + 'mailing_list' => $mailingList, 'failed' => $failed, ]); } diff --git a/src/Entity/TravelMailingList.php b/src/Entity/TravelMailingList.php new file mode 100644 index 00000000..b8afcb8d --- /dev/null +++ b/src/Entity/TravelMailingList.php @@ -0,0 +1,66 @@ +travelCode = $travelCode; + $this->mailjetListId = $mailjetListId; + $this->deleteAfter = $deleteAfter; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTravelCode(): string + { + return $this->travelCode; + } + + public function getMailjetListId(): int + { + return $this->mailjetListId; + } + + public function getDeleteAfter(): \DateTimeImmutable + { + return $this->deleteAfter; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/Mailjet/ApiClient.php b/src/Mailjet/ApiClient.php index 6f8e6182..9013e405 100644 --- a/src/Mailjet/ApiClient.php +++ b/src/Mailjet/ApiClient.php @@ -12,6 +12,7 @@ class ApiClient { private const string DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST'; private const int NAME_MAX_LENGTH = 255; + private const int PAGE_LIMIT = 1000; /** * @param array{firstName?: string, lastName?: string} $contactMetadataFields @@ -141,6 +142,139 @@ class ApiClient ]); } + public function createContactsList(string $name): int + { + $this->assertConfigured(); + + $response = $this->request('POST', 'contactslist', [ + 'json' => [ + 'Name' => $name, + ], + ]); + + $listId = $response['Data'][0]['ID'] ?? null; + if (null === $listId) { + throw new NewsletterProviderException(sprintf('Mailjet did not return an id for the new contact list %s', $name)); + } + + return (int) $listId; + } + + /** + * Adds the contacts as subscribed, including any who unsubscribed from this list before. + * Mailjet processes the request as an asynchronous job. + * + * @param list $contacts + * + * @return int|null the job id + */ + public function addContactsToList(int $listId, array $contacts): ?int + { + $this->assertConfigured(); + + $payload = []; + foreach ($contacts as $contact) { + $entry = ['Email' => $this->normalizeEmail($contact['email'])]; + + $firstName = $this->normalizeName($contact['firstName']); + if (null !== $firstName && true === isset($this->contactMetadataFields['firstName'])) { + $entry['Properties'] = [$this->contactMetadataFields['firstName'] => $firstName]; + } + + $payload[] = $entry; + } + + $response = $this->request('POST', sprintf('contactslist/%d/managemanycontacts', $listId), [ + 'json' => [ + 'Action' => 'addforce', + 'Contacts' => $payload, + ], + ]); + + $jobId = $response['Data'][0]['JobID'] ?? null; + + return null !== $jobId ? (int) $jobId : null; + } + + /** + * @return list + */ + public function getListContactIds(int $listId): array + { + $this->assertConfigured(); + + $contactIds = []; + $offset = 0; + do { + $response = $this->request('GET', 'listrecipient', [ + 'query' => [ + 'ContactsList' => $listId, + 'Limit' => self::PAGE_LIMIT, + 'Offset' => $offset, + ], + ]); + + $entries = true === is_array($response['Data'] ?? null) ? $response['Data'] : []; + foreach ($entries as $entry) { + if (true === is_array($entry) && true === isset($entry['ContactID'])) { + $contactIds[] = (int) $entry['ContactID']; + } + } + + $offset += self::PAGE_LIMIT; + } while (self::PAGE_LIMIT === count($entries)); + + return array_values(array_unique($contactIds)); + } + + /** + * Lists the contact is on, subscribed or not. Mailjet includes lists deleted within the last 60 days. + * + * @return list + */ + public function getContactListIds(int $contactId): array + { + $this->assertConfigured(); + + $response = $this->request('GET', sprintf('contact/%d/getcontactslists', $contactId), [ + 'allow_404' => true, + ]); + + $listIds = []; + foreach ($response['Data'] ?? [] as $entry) { + if (true === is_array($entry) && true === isset($entry['ListID'])) { + $listIds[] = (int) $entry['ListID']; + } + } + + return $listIds; + } + + public function deleteContactsList(int $listId): void + { + $this->assertConfigured(); + + $this->request('DELETE', sprintf('contactslist/%d', $listId), [ + 'allow_404' => true, + 'allow_empty' => true, + ]); + } + + /** + * GDPR deletion: Mailjet anonymises the contact immediately and purges it after 30 days. + * This cannot be undone. + */ + public function deleteContact(int $contactId): void + { + $this->assertConfigured(); + + $this->request('DELETE', sprintf('contacts/%d', $contactId), [ + 'allow_404' => true, + 'allow_empty' => true, + 'api_version' => 'v4', + ]); + } + private function findOrCreateContactId(string $email): int { $contactId = $this->resolveContactId($email); @@ -246,15 +380,17 @@ class ApiClient { $allow404 = true === ($options['allow_404'] ?? false); $allowEmpty = true === ($options['allow_empty'] ?? false); + $baseUrl = 'v4' === ($options['api_version'] ?? null) ? $this->getV4BaseUrl() : $this->getBaseUrl(); unset($options['allow_404']); unset($options['allow_empty']); + unset($options['api_version']); $resourcePath = trim($resource, '/'); try { $response = $this->httpClient->request( $method, - sprintf('%s/%s', $this->getBaseUrl(), $resourcePath), + sprintf('%s/%s', $baseUrl, $resourcePath), array_merge($options, [ 'auth_basic' => sprintf('%s:%s', $this->apiKey, $this->apiSecret), ]) @@ -310,6 +446,16 @@ class ApiClient return rtrim($baseUrl, '/'); } + /** + * The configured base URL points at v3 (`…/v3/REST`); the v4 API sits beside it on the same host. + */ + private function getV4BaseUrl(): string + { + $baseUrl = preg_replace('~/v3/REST$~i', '', $this->getBaseUrl()); + + return sprintf('%s/v4', $baseUrl); + } + private function assertConfigured(): void { if (true === empty($this->apiKey) || true === empty($this->apiSecret)) { diff --git a/src/Repository/TravelMailingListRepository.php b/src/Repository/TravelMailingListRepository.php new file mode 100644 index 00000000..69f839bd --- /dev/null +++ b/src/Repository/TravelMailingListRepository.php @@ -0,0 +1,38 @@ + + */ +class TravelMailingListRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TravelMailingList::class); + } + + public function findOneByTravelCode(string $travelCode): ?TravelMailingList + { + return $this->findOneBy(['travelCode' => $travelCode]); + } + + /** + * @return list + */ + public function findDueForDeletion(\DateTimeImmutable $now): array + { + return $this->createQueryBuilder('l') + ->where('l.deleteAfter <= :now') + ->setParameter('now', $now) + ->orderBy('l.deleteAfter', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Service/TravelDataProvider.php b/src/Service/TravelDataProvider.php index 4e80237c..8e4fd684 100644 --- a/src/Service/TravelDataProvider.php +++ b/src/Service/TravelDataProvider.php @@ -432,6 +432,18 @@ class TravelDataProvider return $this->travelLookupService->mapDateCodeToLabel($dateCode); } + /** + * Map date code to the travel's start date. + * + * @param string $dateCode The date code to map + * + * @return \DateTimeImmutable|null The start date or null if not found + */ + public function mapDateCodeToStartDate(string $dateCode): ?\DateTimeImmutable + { + return $this->travelLookupService->mapDateCodeToStartDate($dateCode); + } + /** * Map hotel code to hotel ID. * diff --git a/src/Service/TravelIndex.php b/src/Service/TravelIndex.php index 2cf4f018..3958856f 100644 --- a/src/Service/TravelIndex.php +++ b/src/Service/TravelIndex.php @@ -127,6 +127,26 @@ class TravelIndex return $this->generateFilesMap()[$dateId]['label'] ?? null; } + /** + * Resolve a date code to the travel's start date. + * + * @param string $dateCode The date code to resolve + * + * @return \DateTimeImmutable|null The start date, or null if the code is not found or the travel has none + */ + public function mapDateCodeToStartDate(string $dateCode): ?\DateTimeImmutable + { + $dateId = $this->mapDateCodeToId($dateCode); + + if (null === $dateId) { + return null; + } + + $dateFrom = $this->generateFilesMap()[$dateId]['dateFrom'] ?? null; + + return $dateFrom instanceof \DateTimeImmutable ? $dateFrom : null; + } + /** * Convert a hotel code string to its corresponding hotel ID. * diff --git a/src/Service/TravelMailingListManager.php b/src/Service/TravelMailingListManager.php new file mode 100644 index 00000000..8849f5c4 --- /dev/null +++ b/src/Service/TravelMailingListManager.php @@ -0,0 +1,142 @@ +mailjet->createContactsList($travelCode); + + // Tracked before the contacts go in, so a list Mailjet already holds is never left without a row + // that the cleanup would find. + $mailingList = new TravelMailingList($travelCode, $listId, $travelStartsAt->modify(self::DELETE_AFTER)); + $this->entityManager->persist($mailingList); + $this->entityManager->flush(); + + try { + $jobId = $this->mailjet->addContactsToList($listId, $this->buildContacts($recipients)); + } catch (NewsletterProviderException $exception) { + $this->discardEmptyList($mailingList); + + throw $exception; + } + + $this->logger->info('Created Mailjet travel mailing list', [ + 'travel_code' => $travelCode, + 'mailjet_list_id' => $listId, + 'mailjet_job_id' => $jobId, + 'recipients' => count($recipients), + ]); + + return $mailingList; + } + + /** + * Deletes the orphaned contacts first and the list last, so a run that fails halfway can be + * repeated: the list still names whoever is left. + * + * @return int the number of contacts deleted + * + * @throws NewsletterProviderException + */ + public function delete(TravelMailingList $mailingList): int + { + $listId = $mailingList->getMailjetListId(); + + $deletedContacts = 0; + foreach ($this->mailjet->getListContactIds($listId) as $contactId) { + // Anyone who is, or recently was, on another list is kept: their newsletter consent or + // another travel's mailing still needs the contact. + if ([$listId] !== array_values(array_unique($this->mailjet->getContactListIds($contactId)))) { + continue; + } + + $this->mailjet->deleteContact($contactId); + ++$deletedContacts; + } + + $this->mailjet->deleteContactsList($listId); + + $this->entityManager->remove($mailingList); + $this->entityManager->flush(); + + $this->logger->info('Deleted Mailjet travel mailing list', [ + 'travel_code' => $mailingList->getTravelCode(), + 'mailjet_list_id' => $listId, + 'deleted_contacts' => $deletedContacts, + ]); + + return $deletedContacts; + } + + /** + * @param TravelMailingRecipient[] $recipients + * + * @return list + */ + private function buildContacts(array $recipients): array + { + // Several participants can share one address; the first name that comes with it wins. + $contacts = []; + foreach ($recipients as $recipient) { + $email = mb_strtolower(trim($recipient->email)); + if ('' === $email || true === isset($contacts[$email])) { + continue; + } + + $contacts[$email] = [ + 'email' => $email, + 'firstName' => '' !== trim($recipient->firstName) ? $recipient->firstName : null, + ]; + } + + return array_values($contacts); + } + + private function discardEmptyList(TravelMailingList $mailingList): void + { + try { + $this->mailjet->deleteContactsList($mailingList->getMailjetListId()); + } catch (NewsletterProviderException $exception) { + // The row stays, so the scheduled cleanup removes the list later. + $this->logger->error('Could not delete Mailjet travel mailing list after a failed import', [ + 'travel_code' => $mailingList->getTravelCode(), + 'mailjet_list_id' => $mailingList->getMailjetListId(), + 'error' => $exception->getMessage(), + ]); + + return; + } + + $this->entityManager->remove($mailingList); + $this->entityManager->flush(); + } +} diff --git a/templates/admin/travel_mailing/index.html.twig b/templates/admin/travel_mailing/index.html.twig index 5dda24fd..d9fc4b72 100644 --- a/templates/admin/travel_mailing/index.html.twig +++ b/templates/admin/travel_mailing/index.html.twig @@ -37,6 +37,31 @@ {{ result.data|length }} Empfänger für {{ result.travelCode }}

+
+ {% if mailing_list is not null %} +

+ Mailjet-Liste {{ mailing_list.travelCode }} (ID {{ mailing_list.mailjetListId }}) + am {{ mailing_list.createdAt|date('d.m.Y, H:i') }} angelegt, + wird ab {{ mailing_list.deleteAfter|date('d.m.Y') }} automatisch gelöscht. +

+ {% elseif travel_starts_at is null %} +

+ Kein Reisebeginn bekannt, daher kann keine Mailjet-Liste angelegt werden. +

+ {% else %} +
+ + + + + Alle Empfänger werden ohne Opt-in eingetragen; die Liste wird 30 Tage nach Reisebeginn + ({{ travel_starts_at|date('d.m.Y') }}) gelöscht. + +
+ {% endif %} +
diff --git a/tests/Mailjet/ApiClientTest.php b/tests/Mailjet/ApiClientTest.php index 1ea9385c..fd565118 100644 --- a/tests/Mailjet/ApiClientTest.php +++ b/tests/Mailjet/ApiClientTest.php @@ -194,4 +194,88 @@ class ApiClientTest extends TestCase 'Action' => 'addforce', ], json_decode((string) $requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR)); } + + public function testAddContactsToListForcesSubscriptionAndSendsOnlyTheFirstName(): void + { + $requests = []; + $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { + $requests[] = [$method, $url, $options]; + + return new MockResponse(json_encode(['Data' => [['JobID' => 555]]], JSON_THROW_ON_ERROR), ['http_code' => 201]); + }); + + $mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', null, [ + 'firstName' => 'vorname', + 'lastName' => 'nachname', + ]); + + $jobId = $mailjet->addContactsToList(42, [ + ['email' => ' Mia@Example.COM ', 'firstName' => ' Mia '], + ['email' => 'anon@example.com', 'firstName' => null], + ]); + + self::assertSame(555, $jobId); + self::assertSame('POST', $requests[0][0]); + self::assertSame('https://mailjet.test/contactslist/42/managemanycontacts', $requests[0][1]); + self::assertSame([ + 'Action' => 'addforce', + 'Contacts' => [ + ['Email' => 'mia@example.com', 'Properties' => ['vorname' => 'Mia']], + ['Email' => 'anon@example.com'], + ], + ], json_decode((string) $requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR)); + } + + public function testCreateContactsListReturnsTheNewListId(): void + { + $requests = []; + $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { + $requests[] = [$method, $url, $options]; + + return new MockResponse(json_encode(['Data' => [['ID' => 42, 'Name' => 'LPJMP281126']]], JSON_THROW_ON_ERROR), ['http_code' => 201]); + }); + + $mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test'); + + self::assertSame(42, $mailjet->createContactsList('LPJMP281126')); + self::assertSame('https://mailjet.test/contactslist', $requests[0][1]); + self::assertSame(['Name' => 'LPJMP281126'], json_decode((string) $requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR)); + } + + public function testGetListContactIdsPagesThroughAllRecipients(): void + { + $offsets = []; + $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$offsets): MockResponse { + $offset = (int) $options['query']['Offset']; + $offsets[] = $offset; + $data = 0 === $offset + ? array_map(static fn (int $id): array => ['ContactID' => $id], range(1, 1000)) + : [['ContactID' => 1001]]; + + return new MockResponse(json_encode(['Data' => $data], JSON_THROW_ON_ERROR)); + }); + + $mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test'); + + $contactIds = $mailjet->getListContactIds(42); + + self::assertSame([0, 1000], $offsets); + self::assertCount(1001, $contactIds); + } + + public function testDeleteContactUsesTheV4Api(): void + { + $requests = []; + $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { + $requests[] = [$method, $url]; + + return new MockResponse('', ['http_code' => 200]); + }); + + $mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://api.mailjet.com/v3/REST'); + + $mailjet->deleteContact(99); + + self::assertSame([['DELETE', 'https://api.mailjet.com/v4/contacts/99']], $requests); + } } diff --git a/tests/Service/TravelIndexTest.php b/tests/Service/TravelIndexTest.php index 7967f643..7f18dd25 100644 --- a/tests/Service/TravelIndexTest.php +++ b/tests/Service/TravelIndexTest.php @@ -186,6 +186,32 @@ class TravelIndexTest extends TestCase $this->assertNull($result); } + // ------------------------------------------------------------------------- + // mapDateCodeToStartDate + // ------------------------------------------------------------------------- + + public function testMapDateCodeToStartDateReturnsTheTravelStartDate(): void + { + $this->travelLoader->method('generateFilesMap')->willReturn([ + 42 => ['id' => 42, 'code' => 'WI25', 'dateFrom' => new \DateTimeImmutable('2026-12-28'), 'hotels' => []], + ]); + $this->travelSnapshotService->method('generateMapping')->willReturn([]); + + $result = $this->service()->mapDateCodeToStartDate('WI25'); + + $this->assertEquals(new \DateTimeImmutable('2026-12-28'), $result); + } + + public function testMapDateCodeToStartDateReturnsNullWhenNotFound(): void + { + $this->travelLoader->method('generateFilesMap')->willReturn([]); + $this->travelSnapshotService->method('generateMapping')->willReturn([]); + + $result = $this->service()->mapDateCodeToStartDate('UNKNOWN'); + + $this->assertNull($result); + } + // ------------------------------------------------------------------------- // mapHotelCodeToId // ------------------------------------------------------------------------- diff --git a/tests/Service/TravelMailingListManagerTest.php b/tests/Service/TravelMailingListManagerTest.php new file mode 100644 index 00000000..a8daed0d --- /dev/null +++ b/tests/Service/TravelMailingListManagerTest.php @@ -0,0 +1,92 @@ +createMock(ApiClient::class); + $mailjet->expects(self::once())->method('createContactsList')->with('LPJMP281126')->willReturn(42); + $mailjet->expects(self::once())->method('addContactsToList')->with(42, [ + ['email' => 'mia@example.com', 'firstName' => 'Mia'], + ['email' => 'tom@example.com', 'firstName' => null], + ])->willReturn(555); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(TravelMailingList::class)); + $entityManager->expects(self::never())->method('remove'); + + $mailingList = (new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))->create('LPJMP281126', [ + new TravelMailingRecipient('Mia@Example.com', 'Mia', 'Muster'), + new TravelMailingRecipient('mia@example.com', 'Lea', 'Muster'), + new TravelMailingRecipient('tom@example.com', '', 'Test'), + ], new \DateTimeImmutable('2026-11-28')); + + self::assertSame(42, $mailingList->getMailjetListId()); + self::assertEquals(new \DateTimeImmutable('2026-12-28'), $mailingList->getDeleteAfter()); + } + + public function testCreateDiscardsTheListWhenTheContactsCannotBeAdded(): void + { + $mailjet = $this->createMock(ApiClient::class); + $mailjet->method('createContactsList')->willReturn(42); + $mailjet->method('addContactsToList')->willThrowException(new NewsletterProviderException('boom')); + $mailjet->expects(self::once())->method('deleteContactsList')->with(42); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('remove')->with(self::isInstanceOf(TravelMailingList::class)); + + $this->expectException(NewsletterProviderException::class); + + (new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))->create('LPJMP281126', [ + new TravelMailingRecipient('mia@example.com', 'Mia', 'Muster'), + ], new \DateTimeImmutable('2026-11-28')); + } + + public function testDeleteRemovesOnlyContactsOnNoOtherList(): void + { + $mailjet = $this->createMock(ApiClient::class); + $mailjet->method('getListContactIds')->with(42)->willReturn([1, 2]); + $mailjet->method('getContactListIds')->willReturnMap([ + [1, [42]], + [2, [42, 10321569]], + ]); + $mailjet->expects(self::once())->method('deleteContact')->with(1); + $mailjet->expects(self::once())->method('deleteContactsList')->with(42); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('remove'); + + $deleted = (new TravelMailingListManager($mailjet, $entityManager, new NullLogger())) + ->delete(new TravelMailingList('LPJMP281126', 42, new \DateTimeImmutable('2026-12-28'))); + + self::assertSame(1, $deleted); + } + + public function testDeleteKeepsTheRowWhenMailjetFails(): void + { + $mailjet = $this->createMock(ApiClient::class); + $mailjet->method('getListContactIds')->willReturn([]); + $mailjet->expects(self::once())->method('deleteContactsList')->willThrowException(new NewsletterProviderException('boom')); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('remove'); + + $this->expectException(NewsletterProviderException::class); + + (new TravelMailingListManager($mailjet, $entityManager, new NullLogger())) + ->delete(new TravelMailingList('LPJMP281126', 42, new \DateTimeImmutable('2026-12-28'))); + } +}