From f05c05fd0b1c55b058dcf9bc9fd10642541ec48e Mon Sep 17 00:00:00 2001
From: Christoph Reiter <reiter.christoph@gmail.com>
Date: Tue, 14 Feb 2023 15:15:58 +0100
Subject: [PATCH] cron: refactor and add unit tests

Move the job execution into manager so it can be tested
---
 src/Cron/CronCommand.php     | 16 +-------
 src/Cron/CronListCommand.php |  2 +-
 src/Cron/CronManager.php     | 71 +++++++++++++++++++++------------
 tests/Cron/CronJob.php       | 48 ++++++++++++++++++++++
 tests/Cron/CronTest.php      | 77 +++++++++++++++++++++++++++++++++---
 5 files changed, 168 insertions(+), 46 deletions(-)
 create mode 100644 tests/Cron/CronJob.php

diff --git a/src/Cron/CronCommand.php b/src/Cron/CronCommand.php
index 521484d..9828c48 100644
--- a/src/Cron/CronCommand.php
+++ b/src/Cron/CronCommand.php
@@ -47,21 +47,7 @@ final class CronCommand extends Command implements LoggerAwareInterface
         $command = $app->find('cache:pool:prune');
         CachePrune::setPruneCommand($command);
 
-        // Now run all jobs
-        if ($force) {
-            $jobsToRun = $this->manager->getAllJobs();
-        } else {
-            $jobsToRun = $this->manager->getDueJobs();
-        }
-        foreach ($jobsToRun as $job) {
-            $name = $job->getName();
-            $this->logger->info("cron: Running '$name'");
-            try {
-                $job->run(new CronOptions());
-            } catch (\Throwable $e) {
-                $this->logger->error("cron: '$name' failed", ['exception' => $e]);
-            }
-        }
+        $this->manager->runDueJobs($force);
 
         return 0;
     }
diff --git a/src/Cron/CronListCommand.php b/src/Cron/CronListCommand.php
index 5dfed2f..f9cb3b6 100644
--- a/src/Cron/CronListCommand.php
+++ b/src/Cron/CronListCommand.php
@@ -38,7 +38,7 @@ final class CronListCommand extends Command implements LoggerAwareInterface
     protected function execute(InputInterface $input, OutputInterface $output): int
     {
         $currentTime = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
-        $jobs = $this->manager->getAllJobs();
+        $jobs = $this->manager->getJobs();
         foreach ($jobs as $job) {
             $output->writeln('<fg=green;options=bold>['.get_class($job).']</>');
             $output->writeln('<fg=blue;options=bold>Name:</> '.$job->getName());
diff --git a/src/Cron/CronManager.php b/src/Cron/CronManager.php
index 7d8bf69..0d937be 100644
--- a/src/Cron/CronManager.php
+++ b/src/Cron/CronManager.php
@@ -42,13 +42,11 @@ final class CronManager implements LoggerAwareInterface
      * Returns if a job should run or not. Note that there is no feedback channel, so if you skip
      * this run you will only be notified the next time the cron job should run.
      *
-     * @param string $cronExpression A cron expression
-     *
      * @return bool If the job should run
      */
-    public static function isDue(?\DateTimeInterface $previousRun, \DateTimeInterface $currentRun, string $cronExpression): bool
+    public static function isDue(CronJobInterface $job, ?\DateTimeInterface $previousRun, \DateTimeInterface $currentRun): bool
     {
-        $cron = new CronExpression($cronExpression);
+        $cron = new CronExpression($job->getInterval());
         $previousExpectedRun = $cron->getPreviousRunDate($currentRun, 0, true);
         $previousExpectedRun->setTimezone(new \DateTimeZone('UTC'));
 
@@ -58,8 +56,9 @@ final class CronManager implements LoggerAwareInterface
             // This can happen on re-deployments, and we don't want a cron-storm there, or jobs that run
             // way off their schedule
             $shouldRun = false;
-        } elseif ($previousExpectedRun > $previousRun && $previousExpectedRun <= $currentRun) {
+        } elseif ($previousExpectedRun->getTimestamp() > $previousRun->getTimestamp() && $previousExpectedRun->getTimestamp() <= $currentRun->getTimestamp()) {
             // If we were scheduled to run between now and right the previous run then we should run
+            // XXX: We compare the timestamps, since that is what we use to serialize the last execution time (so we get the same rounding)
             $shouldRun = true;
         }
 
@@ -79,59 +78,81 @@ final class CronManager implements LoggerAwareInterface
         return \DateTimeImmutable::createFromMutable($nextDate);
     }
 
-    public function getPreviousRun(\DateTimeInterface $currentTime): ?\DateTimeInterface
+    /**
+     * Returns the last time cron was executed.
+     */
+    public function getLastExecutionDate(): ?\DateTimeInterface
     {
         $cachePool = $this->cachePool;
-        // Store the previous run time in the cache and fetch from there
         assert($cachePool instanceof CacheItemPoolInterface);
         $item = $cachePool->getItem('cron-previous-run');
         $value = $item->get();
         $previousRun = null;
         if ($value !== null) {
             $previousRun = (new \DateTimeImmutable())->setTimezone(new \DateTimeZone('UTC'))->setTimestamp($value);
-            if ($previousRun > $currentTime) {
-                // Something is wrong, cap at the current time
-                $previousRun = $currentTime;
-            }
         }
+
+        return $previousRun;
+    }
+
+    /**
+     * Stores the given time as the new last cron execution time.
+     */
+    public function setLastExecutionDate(\DateTimeInterface $currentTime): void
+    {
+        $cachePool = $this->cachePool;
+        assert($cachePool instanceof CacheItemPoolInterface);
+        $item = $cachePool->getItem('cron-previous-run');
         $item->set($currentTime->getTimestamp());
         if ($cachePool->save($item) === false) {
             throw new \RuntimeException('Saving cron timestamp failed');
         }
-
-        return $previousRun;
     }
 
     /**
      * @return CronJobInterface[]
      */
-    public function getAllJobs(): array
+    public function getJobs(): array
     {
         return $this->jobs;
     }
 
-    /**
-     * @return CronJobInterface[]
-     */
-    public function getDueJobs(): array
+    public function runDueJobs(bool $force = false, \DateTimeInterface $currentTime = null)
     {
         // Get all jobs that should have been run between the last time we were called and now
-        $currentTime = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
-        // round to full seconds, so we have the same resolution for both date times
-        $currentTime = $currentTime->setTimestamp($currentTime->getTimestamp());
-        $previousRunTime = $this->getPreviousRun($currentTime);
+        if ($currentTime === null) {
+            $currentTime = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
+        }
+        $lastDate = $this->getLastExecutionDate();
+        $this->setLastExecutionDate($currentTime);
+
+        if ($lastDate === null) {
+            $this->logger->info('cron: No last execution time available, will no run anything');
+        }
 
         $toRun = [];
         foreach ($this->jobs as $job) {
             $interval = $job->getInterval();
             $name = $job->getName();
             $this->logger->info("cron: Checking '$name' ($interval)");
-            $isDue = self::isDue($previousRunTime, $currentTime, $interval);
-            if ($isDue) {
+            $isDue = self::isDue($job, $lastDate, $currentTime);
+            if ($isDue || $force) {
                 $toRun[] = $job;
             }
         }
 
-        return $toRun;
+        if (count($toRun) === 0) {
+            $this->logger->info('cron: No jobs to run');
+        }
+
+        foreach ($toRun as $job) {
+            $name = $job->getName();
+            $this->logger->info("cron: Running '$name'");
+            try {
+                $job->run(new CronOptions());
+            } catch (\Throwable $e) {
+                $this->logger->error("cron: '$name' failed", ['exception' => $e]);
+            }
+        }
     }
 }
diff --git a/tests/Cron/CronJob.php b/tests/Cron/CronJob.php
new file mode 100644
index 0000000..6350caf
--- /dev/null
+++ b/tests/Cron/CronJob.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Dbp\Relay\CoreBundle\Tests\Cron;
+
+use Dbp\Relay\CoreBundle\Cron\CronJobInterface;
+use Dbp\Relay\CoreBundle\Cron\CronOptions;
+
+class CronJob implements CronJobInterface
+{
+    /**
+     * @var string
+     */
+    private $name;
+
+    /**
+     * @var string
+     */
+    private $interval;
+
+    /**
+     * @var bool
+     */
+    public $ran;
+
+    public function __construct(string $interval, string $name = '')
+    {
+        $this->name = $name;
+        $this->interval = $interval;
+        $this->ran = false;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function getInterval(): string
+    {
+        return $this->interval;
+    }
+
+    public function run(CronOptions $options): void
+    {
+        $this->ran = true;
+    }
+}
diff --git a/tests/Cron/CronTest.php b/tests/Cron/CronTest.php
index 7c951cf..a648238 100644
--- a/tests/Cron/CronTest.php
+++ b/tests/Cron/CronTest.php
@@ -4,20 +4,87 @@ declare(strict_types=1);
 
 namespace Dbp\Relay\CoreBundle\Tests\Cron;
 
+use Dbp\Relay\CoreBundle\Cron\CronCommand;
+use Dbp\Relay\CoreBundle\Cron\CronListCommand;
 use Dbp\Relay\CoreBundle\Cron\CronManager;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\Cache\Adapter\ArrayAdapter;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Output\BufferedOutput;
 
 class CronTest extends TestCase
 {
-    public function testCronisDue()
+    public function testCronIsDue()
     {
-        $isDue = CronManager::isDue(new \DateTimeImmutable('2021-09-07T09:36:26Z'), new \DateTimeImmutable('2021-09-07T09:36:26Z'), '* * * * *');
+        $isDue = CronManager::isDue(new CronJob('* * * * *'), new \DateTimeImmutable('2021-09-07T09:36:26Z'), new \DateTimeImmutable('2021-09-07T09:36:26Z'));
         $this->assertFalse($isDue);
-        $isDue = CronManager::isDue(new \DateTimeImmutable('2021-09-07T09:35:59Z'), new \DateTimeImmutable('2021-09-07T09:36:00Z'), '* * * * *');
+        $isDue = CronManager::isDue(new CronJob('* * * * *'), new \DateTimeImmutable('2021-09-07T09:35:59Z'), new \DateTimeImmutable('2021-09-07T09:36:00Z'));
         $this->assertTrue($isDue);
-        $isDue = CronManager::isDue(null, new \DateTimeImmutable('2021-09-07T09:36:00Z'), '0 0 1 1 *');
+        $isDue = CronManager::isDue(new CronJob('0 0 1 1 *'), null, new \DateTimeImmutable('2021-09-07T09:36:00Z'));
         $this->assertFalse($isDue);
-        $isDue = CronManager::isDue(null, new \DateTimeImmutable('2021-09-07T09:36:00Z'), '* * * * *');
+        $isDue = CronManager::isDue(new CronJob('* * * * *'), null, new \DateTimeImmutable('2021-09-07T09:36:00Z'));
         $this->assertFalse($isDue);
     }
+
+    public function testExecutionDate()
+    {
+        $man = new CronManager();
+        $man->setCache(new ArrayAdapter());
+        $this->assertNull($man->getLastExecutionDate());
+        $date = (new \DateTimeImmutable())->setTimestamp(42);
+        $man->setLastExecutionDate($date);
+        $this->assertSame(42, $man->getLastExecutionDate()->getTimestamp());
+    }
+
+    public function testGetNextDate()
+    {
+        $current = (new \DateTimeImmutable())->setTimestamp(1676383110);
+        $next = CronManager::getNextDate(new CronJob('*/5 * * * *'), $current);
+        $this->assertSame(1676383200, $next->getTimestamp());
+    }
+
+    public function testManager()
+    {
+        $last = (new \DateTimeImmutable())->setTimestamp(1676383110);
+        $current = $last->setTimestamp($last->getTimestamp() + 30);
+        $man = new CronManager();
+        $man->setCache(new ArrayAdapter());
+        $man->setLastExecutionDate($last);
+        $job = new CronJob('* * * * *');
+
+        // add a job
+        $man->addJob($job);
+        $this->assertSame($job, $man->getJobs()[0]);
+
+        // run right away
+        $man->runDueJobs(false, $last);
+        $this->assertFalse($job->ran);
+
+        // now a bit later, the job gets run
+        $man->runDueJobs(false, $current);
+        $this->assertTrue($job->ran);
+
+        // now right away again, but force
+        $job->ran = false;
+        $man->runDueJobs(false, $current);
+        $this->assertFalse($job->ran);
+        $man->runDueJobs(true, $current);
+        $this->assertTrue($job->ran);
+    }
+
+    public function testCommands()
+    {
+        $man = new CronManager();
+        $job = new CronJob('* * * * *');
+        $man->addJob($job);
+
+        // cron command
+        new CronCommand($man);
+
+        // list command
+        $input = new ArrayInput([]);
+        $output = new BufferedOutput();
+        $cmd = new CronListCommand($man);
+        $this->assertSame(0, $cmd->run($input, $output));
+    }
 }
-- 
GitLab