diff --git a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php index cacfb9d4..ba9a794e 100644 --- a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php +++ b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php @@ -9,7 +9,6 @@ use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface; use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface; -use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -18,13 +17,15 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; use function Shlinkio\Shlink\Common\buildDateRange; +use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly; use function sprintf; -class MatomoSendVisitsCommand extends Command +class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface { public const NAME = 'integration:matomo:send-visits'; private readonly bool $matomoEnabled; + private SymfonyStyle $io; public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender) { @@ -79,10 +80,10 @@ class MatomoSendVisitsCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { - $io = new SymfonyStyle($input, $output); + $this->io = new SymfonyStyle($input, $output); if (! $this->matomoEnabled) { - $io->warning('Matomo integration is not enabled in this Shlink instance'); + $this->io->warning('Matomo integration is not enabled in this Shlink instance'); return ExitCode::EXIT_WARNING; } @@ -95,50 +96,45 @@ class MatomoSendVisitsCommand extends Command ); if ($input->isInteractive()) { - // TODO Display the resolved date range in case it didn't fail to parse but the value was incorrect - $io->warning([ - 'You are about to send visits in this Shlink instance to Matomo', + $this->io->warning([ + 'You are about to send visits from this Shlink instance to Matomo', + 'Resolved date range -> ' . dateRangeToHumanFriendly($dateRange), 'Shlink will not check for already sent visits, which could result in some duplications. Make sure ' . 'you have verified only visits in the right date range are going to be sent.', ]); - if (! $io->confirm('Continue?', default: false)) { + if (! $this->io->confirm('Continue?', default: false)) { return ExitCode::EXIT_WARNING; } } - $result = $this->visitSender->sendVisitsInDateRange( - $dateRange, - new class ($io, $this->getApplication()) implements VisitSendingProgressTrackerInterface { - public function __construct(private readonly SymfonyStyle $io, private readonly ?Application $app) - { - } - - public function success(int $index): void - { - $this->io->write('.'); - } - - public function error(int $index, Throwable $e): void - { - $this->io->write('E'); - if ($this->io->isVerbose()) { - $this->app?->renderThrowable($e, $this->io); - } - } - }, - ); + $result = $this->visitSender->sendVisitsInDateRange($dateRange, $this); match (true) { - $result->hasFailures() && $result->hasSuccesses() => $io->warning( - sprintf('%s visits sent to Matomo. %s failed', $result->successfulVisits, $result->failedVisits), + $result->hasFailures() && $result->hasSuccesses() => $this->io->warning( + sprintf('%s visits sent to Matomo. %s failed.', $result->successfulVisits, $result->failedVisits), ), - $result->hasFailures() => $io->error( - sprintf('%s visits failed to be sent to Matomo.', $result->failedVisits), + $result->hasFailures() => $this->io->error( + sprintf('Failed to send %s visits to Matomo.', $result->failedVisits), ), - $result->hasSuccesses() => $io->success(sprintf('%s visits sent to Matomo.', $result->successfulVisits)), - default => $io->info('There was no visits matching provided date range'), + $result->hasSuccesses() => $this->io->success( + sprintf('%s visits sent to Matomo.', $result->successfulVisits), + ), + default => $this->io->info('There was no visits matching provided date range.'), }; return ExitCode::EXIT_SUCCESS; } + + public function success(int $index): void + { + $this->io->write('.'); + } + + public function error(int $index, Throwable $e): void + { + $this->io->write('E'); + if ($this->io->isVerbose()) { + $this->getApplication()?->renderThrowable($e, $this->io); + } + } } diff --git a/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php b/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php new file mode 100644 index 00000000..e3a52733 --- /dev/null +++ b/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php @@ -0,0 +1,135 @@ +visitSender = $this->createMock(MatomoVisitSenderInterface::class); + } + + #[Test] + public function warningDisplayedIfIntegrationIsNotEnabled(): void + { + [$output, $exitCode] = $this->executeCommand(matomoEnabled: false); + + self::assertStringContainsString('Matomo integration is not enabled in this Shlink instance', $output); + self::assertEquals(ExitCode::EXIT_WARNING, $exitCode); + } + + #[Test] + #[TestWith([true])] + #[TestWith([false])] + public function warningIsOnlyDisplayedInInteractiveMode(bool $interactive): void + { + $this->visitSender->method('sendVisitsInDateRange')->willReturn(new SendVisitsResult()); + + [$output] = $this->executeCommand(['y'], ['interactive' => $interactive]); + + if ($interactive) { + self::assertStringContainsString('You are about to send visits', $output); + } else { + self::assertStringNotContainsString('You are about to send visits', $output); + } + } + + #[Test] + #[TestWith([true])] + #[TestWith([false])] + public function canCancelExecutionInInteractiveMode(bool $interactive): void + { + $this->visitSender->expects($this->exactly($interactive ? 0 : 1))->method('sendVisitsInDateRange')->willReturn( + new SendVisitsResult(), + ); + $this->executeCommand(['n'], ['interactive' => $interactive]); + } + + #[Test] + #[TestWith([new SendVisitsResult(), 'There was no visits matching provided date range'])] + #[TestWith([new SendVisitsResult(successfulVisits: 10), '10 visits sent to Matomo.'])] + #[TestWith([new SendVisitsResult(successfulVisits: 2), '2 visits sent to Matomo.'])] + #[TestWith([new SendVisitsResult(failedVisits: 238), 'Failed to send 238 visits to Matomo.'])] + #[TestWith([new SendVisitsResult(failedVisits: 18), 'Failed to send 18 visits to Matomo.'])] + #[TestWith([new SendVisitsResult(successfulVisits: 2, failedVisits: 35), '2 visits sent to Matomo. 35 failed.'])] + #[TestWith([new SendVisitsResult(successfulVisits: 81, failedVisits: 6), '81 visits sent to Matomo. 6 failed.'])] + public function expectedResultIsDisplayed(SendVisitsResult $result, string $expectedResultMessage): void + { + $this->visitSender->expects($this->once())->method('sendVisitsInDateRange')->willReturn($result); + [$output, $exitCode] = $this->executeCommand(['y']); + + self::assertStringContainsString($expectedResultMessage, $output); + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); + } + + #[Test] + public function printsResultOfSendingVisits(): void + { + $this->visitSender->method('sendVisitsInDateRange')->willReturnCallback( + function (DateRange $_, MatomoSendVisitsCommand $command): SendVisitsResult { + // Call it a few times for an easier match of its result in the command putput + $command->success(0); + $command->success(1); + $command->success(2); + $command->error(3, new Exception('Error')); + $command->success(4); + $command->error(5, new Exception('Error')); + + return new SendVisitsResult(); + }, + ); + + [$output] = $this->executeCommand(['y']); + + self::assertStringContainsString('...E.E', $output); + } + + #[Test] + #[TestWith([[], 'All time'])] + #[TestWith([['--since' => '2023-05-01'], 'Since 2023-05-01 00:00:00'])] + #[TestWith([['--until' => '2023-05-01'], 'Until 2023-05-01 00:00:00'])] + #[TestWith([ + ['--since' => '2023-05-01', '--until' => '2024-02-02 23:59:59'], + 'Between 2023-05-01 00:00:00 and 2024-02-02 23:59:59', + ])] + public function providedDateAreParsed(array $args, string $expectedMessage): void + { + [$output] = $this->executeCommand(['n'], args: $args); + self::assertStringContainsString('Resolved date range -> ' . $expectedMessage, $output); + } + + /** + * @return array{string, int, MatomoSendVisitsCommand} + */ + private function executeCommand( + array $input = [], + array $options = [], + array $args = [], + bool $matomoEnabled = true, + ): array { + $command = new MatomoSendVisitsCommand(new MatomoOptions(enabled: $matomoEnabled), $this->visitSender); + $commandTester = CliTestUtils::testerForCommand($command); + $commandTester->setInputs($input); + $commandTester->execute($args, $options); + + $output = $commandTester->getDisplay(); + $exitCode = $commandTester->getStatusCode(); + + return [$output, $exitCode, $command]; + } +} diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 5ba45ac2..2e238e99 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -61,6 +61,23 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en return buildDateRange($startDate, $endDate); } +function dateRangeToHumanFriendly(?DateRange $dateRange): string +{ + $startDate = $dateRange?->startDate; + $endDate = $dateRange?->endDate; + + return match (true) { + $startDate !== null && $endDate !== null => sprintf( + 'Between %s and %s', + $startDate->toDateTimeString(), + $endDate->toDateTimeString(), + ), + $startDate !== null => sprintf('Since %s', $startDate->toDateTimeString()), + $endDate !== null => sprintf('Until %s', $endDate->toDateTimeString()), + default => 'All time', + }; +} + /** * @return ($date is null ? null : Chronos) */ diff --git a/module/Core/test/Matomo/MatomoVisitSenderTest.php b/module/Core/test/Matomo/MatomoVisitSenderTest.php index aebdf7e0..816c8eea 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Matomo; use Exception; -use Laminas\Validator\Date; use MatomoTracker; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test;