diff --git a/CHANGELOG.md b/CHANGELOG.md
index fae2c595..4c5d1e45 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+- [#167](https://github.com/itk-dev/devops_itksites/pull/167)
+ Let the latest dir result decide which sites exist, so replays no longer
+ revive removed sites or delete live ones, and purge detection results not
+ seen for 30 days every night
- [#166](https://github.com/itk-dev/devops_itksites/pull/166)
Update EasyAdmin to 5.7, which adds the UI Kit at `/admin/_ea_ui_kit` in
debug mode
diff --git a/README.md b/README.md
index 06197c5e..3c9d0817 100644
--- a/README.md
+++ b/README.md
@@ -201,7 +201,7 @@ docker compose exec phpfpm composer queues
or
```shell
-docker compose exec phpfpm bin/console messenger:consume async --failure-limit=1 -vvv
+docker compose exec phpfpm bin/console messenger:consume async -vvv
```
### Assets
diff --git a/Taskfile.yml b/Taskfile.yml
index c17a696f..a4807ea8 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -59,7 +59,7 @@ tasks:
cmds:
- task: console
vars:
- CONSOLE_ARGS: messenger:consume async --failure-limit=1 -vvv
+ CONSOLE_ARGS: messenger:consume async -vvv
composer:
desc: "Run `composer` command. Example: task composer -- normalize"
diff --git a/composer.json b/composer.json
index 212f9c70..053673da 100644
--- a/composer.json
+++ b/composer.json
@@ -139,7 +139,7 @@
"bin/console hautelook:fixtures:load --no-interaction"
],
"queues": [
- "bin/console messenger:consume async scheduler_drupal_releases --failure-limit=1 -vvv"
+ "bin/console messenger:consume async scheduler_default -vvv"
],
"tests": [
"bin/console --env=test doctrine:database:drop --if-exists --force --quiet",
diff --git a/docker-compose.server.override.yml b/docker-compose.server.override.yml
index 8eb7ce73..994958e4 100644
--- a/docker-compose.server.override.yml
+++ b/docker-compose.server.override.yml
@@ -24,7 +24,7 @@ services:
restart: unless-stopped
stop_grace_period: 20s
environment:
- - APP_SUPERVISOR_COMMAND=/app/bin/console messenger:consume --env=prod --no-debug --time-limit=900 --failure-limit=1 async scheduler_drupal_releases
+ - APP_SUPERVISOR_COMMAND=/app/bin/console messenger:consume --env=prod --no-debug --time-limit=900 async scheduler_default
- APP_SUPERVISOR_WORKERS=1
- APP_SUPERVISOR_USER=deploy
- PHP_MEMORY_LIMIT=256M
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
index b723202c..f135648a 100644
--- a/phpstan.dist.neon
+++ b/phpstan.dist.neon
@@ -23,4 +23,3 @@ parameters:
- src/Entity/
- src/Form/
- src/Service/
- - src/Handler/
diff --git a/src/Command/PurgeCommand.php b/src/Command/PurgeCommand.php
index d37cbad1..32981dc2 100644
--- a/src/Command/PurgeCommand.php
+++ b/src/Command/PurgeCommand.php
@@ -11,11 +11,13 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;
#[AsCommand(
name: 'app:data:purge',
description: 'Purge old data',
)]
+#[AsPeriodicTask(frequency: '1 day', from: '03:00', jitter: 1800, arguments: '--date="30 days ago"')]
class PurgeCommand extends Command
{
public function __construct(
@@ -29,17 +31,17 @@ public function __construct(
*/
protected function configure(): void
{
- $this->addOption('date', null, InputOption::VALUE_REQUIRED, 'Remove results older that this date (Y-m-d)');
+ $this->addOption('date', null, InputOption::VALUE_REQUIRED, 'Remove results not seen since this date, e.g. "2026-09-21" or "30 days ago"');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
- $format = 'Y-m-d';
$date = (string) $input->getOption('date');
- $dateTime = \DateTime::createFromFormat($format, $date);
- if (false === $dateTime || $dateTime->format($format) !== $date) {
+ try {
+ $dateTime = new \DateTime($date);
+ } catch (\DateMalformedStringException) {
$output->writeln('Unknown date format: '.$date);
return Command::FAILURE;
diff --git a/src/Handler/DirectoryHandler.php b/src/Handler/DirectoryHandler.php
index 5b7cca17..ea71f7c4 100644
--- a/src/Handler/DirectoryHandler.php
+++ b/src/Handler/DirectoryHandler.php
@@ -10,7 +10,9 @@
use App\Repository\SiteRepository;
use App\Service\InstallationFactory;
use App\Types\DetectionType;
+use App\Utils\RootDirNormalizer;
use Doctrine\Common\Collections\Collection;
+use Doctrine\ORM\EntityManagerInterface;
/**
* Handler for DetectionResult off type "dir" (Installations).
@@ -24,34 +26,38 @@ public function __construct(
private SiteRepository $siteRepository,
private InstallationFactory $installationFactory,
private DetectionResultRepository $detectionResultRepository,
+ private EntityManagerInterface $entityManager,
) {
}
public function handleResult(DetectionResult $detectionResult): void
{
$server = $detectionResult->getServer();
- $oldInstallations = $server->getInstallations();
+
+ // Only the latest dir result describes the server. Older ones (e.g. from a replay) are history.
+ $latest = $this->detectionResultRepository->findOneBy(['server' => $server, 'type' => DetectionType::DIRECTORY], ['lastContact' => 'DESC']);
+ if ($latest !== $detectionResult) {
+ return;
+ }
$installations = $this->installationFactory->getInstallations($detectionResult);
- /** @var Installation $installation */
- foreach ($installations as $installation) {
- $sites = $this->siteRepository->findByRootDirAndServer(
- $installation->getRootDir(),
- $server
- );
- /* @var Site $site */
- foreach ($sites as $site) {
+ // Sites belong to the listed dir they are in. Sites outside every listed dir are gone.
+ foreach ($this->siteRepository->findBy(['server' => $server]) as $site) {
+ $installation = $this->findEnclosing($installations, $site->getRootDir());
+ if (null === $installation) {
+ $this->entityManager->remove($site);
+ } else {
$installation->addSite($site);
}
}
- $detectionResult->getServer()->setInstallations($installations);
+ $server->setInstallations($installations);
- // Delete results from installations no longer seen on the server.
- foreach ($oldInstallations as $oldInstallation) {
- if (!$installations->contains($oldInstallation)) {
- $this->detectionResultRepository->deleteByInstallation($oldInstallation);
+ // Delete results from dirs no longer listed, so a replay cannot recreate them.
+ foreach ($this->detectionResultRepository->findBy(['server' => $server]) as $result) {
+ if (DetectionType::DIRECTORY !== $result->getType() && null === $this->findEnclosing($installations, $result->getRootDir())) {
+ $this->entityManager->remove($result);
}
}
}
@@ -61,15 +67,23 @@ public function supportsType(string $type): bool
return DetectionType::DIRECTORY === $type;
}
- private function getRemovedInstallations(Collection $oldInstallations, Collection $newInstallations): array
+ /**
+ * Find the installation for the root dir itself or, failing that, its closest parent dir.
+ *
+ * @param Collection $installations
+ */
+ private function findEnclosing(Collection $installations, string $rootDir): ?Installation
{
- $result = [];
- foreach ($oldInstallations as $installation) {
- if (!$newInstallations->contains($installation)) {
- $result[] = $installation;
+ $rootDir = RootDirNormalizer::normalize($rootDir);
+ $enclosing = null;
+ foreach ($installations as $installation) {
+ $dir = $installation->getRootDir();
+ if (($rootDir === $dir || str_starts_with($rootDir, $dir.'/'))
+ && strlen($dir) > strlen($enclosing?->getRootDir() ?? '')) {
+ $enclosing = $installation;
}
}
- return $result;
+ return $enclosing;
}
}
diff --git a/src/MessageHandler/PersistDetectionResultHandler.php b/src/MessageHandler/PersistDetectionResultHandler.php
index c745f155..24bc0008 100644
--- a/src/MessageHandler/PersistDetectionResultHandler.php
+++ b/src/MessageHandler/PersistDetectionResultHandler.php
@@ -8,6 +8,7 @@
use App\Message\ProcessDetectionResult;
use App\Repository\DetectionResultRepository;
use App\Repository\ServerRepository;
+use App\Types\DetectionType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
@@ -58,6 +59,12 @@ public function __invoke(PersistDetectionResult $message): void
$existingResult->setLastContact($message->receivedAt);
$this->entityManager->flush();
+
+ // Except dir results: they decide which installations exist, so re-apply them to
+ // remove anything recreated since they were first processed (e.g. by a replay).
+ if (DetectionType::DIRECTORY === $existingResult->getType() && null !== $id = $existingResult->getId()) {
+ $this->messageBus->dispatch(new ProcessDetectionResult($id));
+ }
}
$this->entityManager->clear();
diff --git a/src/Repository/DetectionResultRepository.php b/src/Repository/DetectionResultRepository.php
index 56364897..0f304f4a 100644
--- a/src/Repository/DetectionResultRepository.php
+++ b/src/Repository/DetectionResultRepository.php
@@ -5,7 +5,6 @@
namespace App\Repository;
use App\Entity\DetectionResult;
-use App\Entity\Installation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -89,18 +88,4 @@ public function cleanup(DetectionResult $detectionResult, int $keep = 5, bool $f
$em->flush();
}
}
-
- /**
- * Delete all detection results for a given installation.
- */
- public function deleteByInstallation(Installation $installation): void
- {
- $qb = $this->getEntityManager()->createQueryBuilder();
- $qb->delete(DetectionResult::class, 'd')
- ->where('d.server = :server')
- ->andWhere('d.rootDir =:rootDir')
- ->setParameter('server', $installation->getServer())
- ->setParameter('rootDir', $installation->getRootDir());
- $qb->getQuery()->execute();
- }
}
diff --git a/src/Repository/SiteRepository.php b/src/Repository/SiteRepository.php
index d4df5296..f8ed961e 100644
--- a/src/Repository/SiteRepository.php
+++ b/src/Repository/SiteRepository.php
@@ -4,7 +4,6 @@
namespace App\Repository;
-use App\Entity\Server;
use App\Entity\Site;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\AbstractQuery;
@@ -25,18 +24,6 @@ public function __construct(ManagerRegistry $registry)
parent::__construct($registry, Site::class);
}
- public function findByRootDirAndServer(string $rootDir, Server $server): mixed
- {
- return $this->createQueryBuilder('s')
- ->andWhere('s.rootDir LIKE :rootDir')
- ->setParameter('rootDir', $rootDir.'%')
- ->andWhere('s.server = :server')
- ->setParameter('server', $server->getId()->toBinary())
- ->getQuery()
- ->getResult()
- ;
- }
-
/**
* Get unique primary domains from existing sites.
*/
diff --git a/src/Scheduler/DrupalReleaseSchedule.php b/src/Scheduler/DefaultSchedule.php
similarity index 62%
rename from src/Scheduler/DrupalReleaseSchedule.php
rename to src/Scheduler/DefaultSchedule.php
index 01b13ce4..3c7f2e62 100644
--- a/src/Scheduler/DrupalReleaseSchedule.php
+++ b/src/Scheduler/DefaultSchedule.php
@@ -12,13 +12,14 @@
use Symfony\Contracts\Cache\CacheInterface;
/**
- * Check drupal.org releases every 12 hours.
+ * The app's one schedule. Tasks declared with #[AsPeriodicTask] or
+ * #[AsCronTask] join it, e.g. app:data:purge.
*
- * Anchored and stateful because the worker restarts every 15 minutes
- * (--time-limit=900), which would otherwise reset the clock on each boot.
+ * Stateful because the worker restarts every 15 minutes (--time-limit=900),
+ * which would otherwise reset the clock on each boot and skip runs.
*/
-#[AsSchedule('drupal_releases')]
-final readonly class DrupalReleaseSchedule implements ScheduleProviderInterface
+#[AsSchedule]
+final readonly class DefaultSchedule implements ScheduleProviderInterface
{
public function __construct(
private CacheInterface $cache,
@@ -28,6 +29,7 @@ public function __construct(
public function getSchedule(): Schedule
{
return new Schedule()
+ // Check drupal.org releases every 12 hours.
->add(RecurringMessage::every('12 hours', new CheckDrupalReleases(), from: '06:00'))
->stateful($this->cache);
}
diff --git a/tests/Command/PurgeCommandTest.php b/tests/Command/PurgeCommandTest.php
new file mode 100644
index 00000000..50ba149a
--- /dev/null
+++ b/tests/Command/PurgeCommandTest.php
@@ -0,0 +1,78 @@
+get(EntityManagerInterface::class);
+ $server = $entityManager->getRepository(Server::class)->findOneBy([]);
+ foreach (['-31 days' => 'old', '-29 days' => 'recent'] as $lastContact => $data) {
+ $result = new DetectionResult();
+ $result->setType(DetectionType::DOCKER);
+ $result->setRootDir(self::ROOT_DIR);
+ $result->setServer($server);
+ $result->setData($data);
+ $result->generateHash();
+ $result->setLastContact(new \DateTimeImmutable($lastContact));
+ $entityManager->persist($result);
+ }
+ $entityManager->flush();
+
+ $tester = $this->console(['--date' => '30 days ago']);
+
+ $tester->assertCommandIsSuccessful();
+ $entityManager->clear();
+ $remaining = $entityManager->getRepository(DetectionResult::class)->findBy(['rootDir' => self::ROOT_DIR]);
+ $this->assertSame(['recent'], array_map(static fn (DetectionResult $result): string => $result->getData(), $remaining));
+ }
+
+ public function testRejectsAnUnparsableDate(): void
+ {
+ self::bootKernel();
+
+ $this->console(['--date' => 'not a date'])->assertCommandFailed();
+ }
+
+ public function testRunsDailyOnTheDefaultSchedule(): void
+ {
+ self::bootKernel();
+
+ $tester = $this->console(['command' => 'debug:scheduler', 'schedule' => ['default']]);
+
+ $tester->assertCommandIsSuccessful();
+ $this->assertStringContainsString('every 1 day with 0-1800 second jitter', $tester->getDisplay());
+ $this->assertStringContainsString('app:data:purge --date="30 days ago"', $tester->getDisplay());
+ }
+
+ /**
+ * @param array $input
+ */
+ private function console(array $input): ApplicationTester
+ {
+ $application = new Application(self::$kernel);
+ $application->setAutoExit(false);
+
+ $tester = new ApplicationTester($application);
+ $tester->run($input + ['command' => 'app:data:purge']);
+
+ return $tester;
+ }
+}
diff --git a/tests/Handler/DirectoryHandlerTest.php b/tests/Handler/DirectoryHandlerTest.php
new file mode 100644
index 00000000..1c5e13b5
--- /dev/null
+++ b/tests/Handler/DirectoryHandlerTest.php
@@ -0,0 +1,149 @@
+entityManager = self::getContainer()->get(EntityManagerInterface::class);
+ $this->serverId = (string) $this->entityManager->getRepository(Server::class)->findOneBy([])->getId();
+ }
+
+ public function testRemovesInstallationsNoLongerInDirResult(): void
+ {
+ $this->process(DetectionType::DIRECTORY, '/data/www', [self::KEPT_DIR, self::GONE_DIR], new \DateTimeImmutable('-1 day'));
+ $this->process(DetectionType::DOCKER, self::KEPT_DIR, $this->dockerData('kept-probe.example.com'));
+ $goneDockerResultId = $this->process(DetectionType::DOCKER, self::GONE_DIR, $this->dockerData('gone-probe.example.com'));
+
+ $this->assertNotNull($this->findInstallation(self::GONE_DIR));
+
+ $this->process(DetectionType::DIRECTORY, '/data/www', [self::KEPT_DIR]);
+
+ $this->assertNull($this->findInstallation(self::GONE_DIR));
+ $this->assertNull($this->entityManager->getRepository(Site::class)->findOneBy(['rootDir' => self::GONE_DIR]));
+ $this->assertNull($this->entityManager->getRepository(DetectionResult::class)->find($goneDockerResultId));
+
+ $this->assertNotNull($this->findInstallation(self::KEPT_DIR));
+ $this->assertNotNull($this->entityManager->getRepository(Site::class)->findOneBy(['rootDir' => self::KEPT_DIR]));
+ }
+
+ public function testIgnoresDirResultsOlderThanTheLatest(): void
+ {
+ $this->process(DetectionType::DIRECTORY, '/data/www', [self::KEPT_DIR, self::GONE_DIR]);
+ $this->process(DetectionType::DOCKER, self::GONE_DIR, $this->dockerData('gone-probe.example.com'));
+
+ // E.g. replaying a dir result from before GONE_DIR was added.
+ $this->process(DetectionType::DIRECTORY, '/data', [self::KEPT_DIR], new \DateTimeImmutable('-1 day'));
+
+ $this->assertNotNull($this->findInstallation(self::GONE_DIR));
+ $this->assertNotNull($this->entityManager->getRepository(Site::class)->findOneBy(['rootDir' => self::GONE_DIR]));
+ $this->assertNotNull($this->entityManager->getRepository(DetectionResult::class)->findOneBy(['rootDir' => self::GONE_DIR]));
+ }
+
+ public function testKeepsOnlySitesInsideAListedDir(): void
+ {
+ $subDir = self::KEPT_DIR.'/web';
+ $lookAlikes = [
+ '/data/www/kept.probe/htdocs', // matched "kept_probe" when "_" was a LIKE wildcard
+ self::KEPT_DIR.'_old', // matched as a prefix without a "/" boundary
+ ];
+
+ $this->process(DetectionType::DIRECTORY, '/data/www', [self::KEPT_DIR], new \DateTimeImmutable('-1 day'));
+ foreach ([$subDir, ...$lookAlikes] as $i => $rootDir) {
+ $this->process(DetectionType::DOCKER, $rootDir, $this->dockerData('probe'.$i.'.example.com'));
+ }
+
+ // The state prod is in: look-alike sites moved onto the kept installation, their own installations gone.
+ $siteRepository = $this->entityManager->getRepository(Site::class);
+ $kept = $this->findInstallation(self::KEPT_DIR);
+ foreach ($lookAlikes as $rootDir) {
+ $site = $siteRepository->findOneBy(['rootDir' => $rootDir]);
+ $this->entityManager->remove($site->getInstallation());
+ $kept->addSite($site);
+ }
+ $this->entityManager->flush();
+
+ $this->process(DetectionType::DIRECTORY, '/data', [self::KEPT_DIR]);
+
+ $this->entityManager->clear();
+ foreach ($lookAlikes as $rootDir) {
+ $this->assertNull($siteRepository->findOneBy(['rootDir' => $rootDir]), $rootDir);
+ $this->assertNull($this->entityManager->getRepository(DetectionResult::class)->findOneBy(['rootDir' => $rootDir]), $rootDir);
+ }
+ $this->assertSame(self::KEPT_DIR, $siteRepository->findOneBy(['rootDir' => $subDir])?->getInstallation()->getRootDir());
+ $this->assertNotNull($this->entityManager->getRepository(DetectionResult::class)->findOneBy(['rootDir' => $subDir]));
+ }
+
+ /**
+ * @return array
+ */
+ private function dockerData(string $domain): array
+ {
+ return [
+ 'domain' => $domain,
+ 'containers' => [[
+ 'name' => 'phpfpm',
+ 'image' => 'itkdev/php8.4-fpm',
+ 'packages' => 'unknown',
+ 'audit' => 'unknown',
+ 'drupal' => 'unknown',
+ 'symfony' => 'unknown',
+ ]],
+ ];
+ }
+
+ /**
+ * Persist a detection result and run it through the real processing flow.
+ *
+ * @param array $data
+ */
+ private function process(string $type, string $rootDir, array $data, ?\DateTimeImmutable $lastContact = null): string
+ {
+ $server = $this->entityManager->getRepository(Server::class)->find($this->serverId);
+
+ $detectionResult = new DetectionResult();
+ $detectionResult->setType($type);
+ $detectionResult->setRootDir($rootDir);
+ $detectionResult->setServer($server);
+ $detectionResult->setData(json_encode($data, JSON_THROW_ON_ERROR));
+ $detectionResult->generateHash();
+ $detectionResult->setLastContact($lastContact);
+ $this->entityManager->persist($detectionResult);
+ $this->entityManager->flush();
+
+ $id = $detectionResult->getId();
+ self::getContainer()->get(ProcessDetectionResultHandler::class)(new ProcessDetectionResult($id));
+
+ return (string) $id;
+ }
+
+ private function findInstallation(string $rootDir): ?Installation
+ {
+ $this->entityManager->clear();
+
+ return $this->entityManager->getRepository(Installation::class)->findOneBy(['rootDir' => $rootDir]);
+ }
+}
diff --git a/tests/MessageHandler/PersistDetectionResultHandlerTest.php b/tests/MessageHandler/PersistDetectionResultHandlerTest.php
index 830d8e75..ff041e91 100644
--- a/tests/MessageHandler/PersistDetectionResultHandlerTest.php
+++ b/tests/MessageHandler/PersistDetectionResultHandlerTest.php
@@ -12,6 +12,7 @@
use App\Repository\DetectionResultRepository;
use App\Repository\ServerRepository;
use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -19,10 +20,10 @@
class PersistDetectionResultHandlerTest extends KernelTestCase
{
- private ServerRepository $serverRepositoryMock;
- private DetectionResultRepository $detectionResultRepositoryMock;
- private EntityManagerInterface $entityManagerMock;
- private MessageBusInterface $messageBusMock;
+ private ServerRepository&MockObject $serverRepositoryMock;
+ private DetectionResultRepository&MockObject $detectionResultRepositoryMock;
+ private EntityManagerInterface&MockObject $entityManagerMock;
+ private MessageBusInterface&MockObject $messageBusMock;
private PersistDetectionResultHandler $handler;
private DetectionResult $detectionResult;
@@ -78,6 +79,8 @@ public function testInvokeNewDetectionResult(): void
public function testInvokeExistingDetectionResult(): void
{
+ $this->detectionResult->setType('docker');
+
$apiKey = '12345678';
$receivedAt = new \DateTimeImmutable();
$detectionResultReceived = new PersistDetectionResult($this->detectionResult, $apiKey, $receivedAt);
@@ -99,4 +102,27 @@ public function testInvokeExistingDetectionResult(): void
// "lastContact" should only be updated for the existing result
$this->assertEquals($receivedAt, $existingResult->getLastContact());
}
+
+ public function testInvokeExistingDirDetectionResultIsReprocessed(): void
+ {
+ $receivedAt = new \DateTimeImmutable();
+ $detectionResultReceived = new PersistDetectionResult($this->detectionResult, '12345678', $receivedAt);
+
+ $existingResult = new DetectionResult();
+ $existingResult->setId(new Ulid());
+ $existingResult->setType('dir');
+
+ $this->detectionResultRepositoryMock->expects($this->once())->method('findOneBy')
+ ->willReturn($existingResult);
+ $this->entityManagerMock->expects($this->never())->method('persist');
+
+ // The dir list decides which installations exist, so it is re-applied on every harvest.
+ $this->messageBusMock->expects($this->once())->method('dispatch')
+ ->with(new ProcessDetectionResult($existingResult->getId()))
+ ->willReturn(new Envelope(new ProcessDetectionResult(new Ulid())));
+
+ $this->handler->__invoke($detectionResultReceived);
+
+ $this->assertEquals($receivedAt, $existingResult->getLastContact());
+ }
}
diff --git a/tests/Scheduler/DefaultScheduleTest.php b/tests/Scheduler/DefaultScheduleTest.php
new file mode 100644
index 00000000..3eaeb858
--- /dev/null
+++ b/tests/Scheduler/DefaultScheduleTest.php
@@ -0,0 +1,51 @@
+get('messenger.receiver_locator');
+
+ $this->assertTrue($receivers->has('scheduler_default'));
+ $this->assertFalse($receivers->has('scheduler_drupal_releases'));
+ }
+
+ public function testIsStatefulAndHoldsTheDrupalCheckAndThePurge(): void
+ {
+ self::bootKernel();
+ /** @var Schedule $schedule */
+ $schedule = self::getContainer()->get('scheduler.provider.default');
+
+ // Stateful, so the worker's 15 minute restarts do not skip runs.
+ $this->assertNotNull($schedule->getState());
+
+ $messages = [];
+ foreach ($schedule->getRecurringMessages() as $message) {
+ $messages[$this->messageOf($message)::class] = $message;
+ }
+ $this->assertEqualsCanonicalizing([CheckDrupalReleases::class, RunCommandMessage::class], array_keys($messages));
+
+ $trigger = $messages[CheckDrupalReleases::class]->getTrigger();
+ $this->assertEquals(new \DateTimeImmutable('18:00'), $trigger->getNextRunDate(new \DateTimeImmutable('07:00')));
+ $this->assertEquals(new \DateTimeImmutable('tomorrow 06:00'), $trigger->getNextRunDate(new \DateTimeImmutable('19:00')));
+ }
+
+ private function messageOf(RecurringMessage $message): object
+ {
+ $context = new MessageContext('default', $message->getId(), $message->getTrigger(), new \DateTimeImmutable());
+
+ return iterator_to_array($message->getMessages($context))[0];
+ }
+}
diff --git a/tests/Scheduler/DrupalReleaseScheduleTest.php b/tests/Scheduler/DrupalReleaseScheduleTest.php
deleted file mode 100644
index 224cc2d6..00000000
--- a/tests/Scheduler/DrupalReleaseScheduleTest.php
+++ /dev/null
@@ -1,39 +0,0 @@
-assertTrue(self::getContainer()->get('messenger.receiver_locator')->has('scheduler_drupal_releases'));
- }
-
- public function testRecursEvery12HoursFrom0600AndIsStateful(): void
- {
- self::bootKernel();
- $schedule = self::getContainer()->get(DrupalReleaseSchedule::class)->getSchedule();
-
- $this->assertNotNull($schedule->getState());
-
- $messages = $schedule->getRecurringMessages();
- $this->assertCount(1, $messages);
- $message = $messages[0];
- $trigger = $message->getTrigger();
-
- $context = new MessageContext('drupal_releases', $message->getId(), $trigger, new \DateTimeImmutable());
- $this->assertInstanceOf(CheckDrupalReleases::class, iterator_to_array($message->getMessages($context))[0]);
-
- $this->assertEquals(new \DateTimeImmutable('18:00'), $trigger->getNextRunDate(new \DateTimeImmutable('07:00')));
- $this->assertEquals(new \DateTimeImmutable('tomorrow 06:00'), $trigger->getNextRunDate(new \DateTimeImmutable('19:00')));
- }
-}