From d0017ba335556cb16a78411a9897e14169d645a3 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 11:41:38 +0100 Subject: [PATCH 01/16] Fix bug of silently dropped stamps --- CHANGELOG.md | 6 ++++++ src/Manager/TaskManager.php | 3 +-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52efe79..6f3f6d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +3.2.4 +===== + +* (bug) Fix stamps passed to `TaskManager::enqueue()` being silently dropped due to `Envelope::with()` being immutable. + + 3.2.3 ===== diff --git a/src/Manager/TaskManager.php b/src/Manager/TaskManager.php index ed9ae07..b1be0f3 100644 --- a/src/Manager/TaskManager.php +++ b/src/Manager/TaskManager.php @@ -38,8 +38,7 @@ public function enqueue (Task $task, array $stamps = []) : bool return false; } - $envelope = new Envelope($task); - $envelope->with(...$stamps); + $envelope = new Envelope($task, $stamps); $this->messageBus->dispatch($envelope); From dafc30d0e21099b5f0b2151c145045ba7cc2355c Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 11:42:21 +0100 Subject: [PATCH 02/16] Add index on task log table --- CHANGELOG.md | 1 + src/Entity/TaskLog.php | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f3f6d7..759d895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ===== * (bug) Fix stamps passed to `TaskManager::enqueue()` being silently dropped due to `Envelope::with()` being immutable. +* (improvement) Add database index on `time_queued` column of `task_manager_tasks` for faster log queries and cleanup. 3.2.3 diff --git a/src/Entity/TaskLog.php b/src/Entity/TaskLog.php index e7bbfb4..a6bfec0 100644 --- a/src/Entity/TaskLog.php +++ b/src/Entity/TaskLog.php @@ -24,6 +24,7 @@ */ #[ORM\Entity] #[ORM\Table(name: "task_manager_tasks")] +#[ORM\Index(columns: ["time_queued"], name: "idx_task_manager_tasks_time_queued")] class TaskLog { /** From 297b9ad9c31741a47bf385b1c8b596b17016b221 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 11:44:00 +0100 Subject: [PATCH 03/16] Remove obsolete paginator --- CHANGELOG.md | 1 + src/Model/TaskLogModel.php | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 759d895..2b17787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * (bug) Fix stamps passed to `TaskManager::enqueue()` being silently dropped due to `Envelope::with()` being immutable. * (improvement) Add database index on `time_queued` column of `task_manager_tasks` for faster log queries and cleanup. +* (internal) Remove unused `Paginator` wrapper in `TaskLogModel::getMostRecentEntries()`. 3.2.3 diff --git a/src/Model/TaskLogModel.php b/src/Model/TaskLogModel.php index 6876434..fd2167c 100644 --- a/src/Model/TaskLogModel.php +++ b/src/Model/TaskLogModel.php @@ -4,7 +4,6 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; -use Doctrine\ORM\Tools\Pagination\Paginator; use Psr\Log\LoggerInterface; use Torr\TaskManager\Entity\TaskLog; use Torr\TaskManager\Entity\TaskRun; @@ -92,9 +91,7 @@ public function getMostRecentEntries ( } /** @var TaskLog[] */ - return (new Paginator($builder->getQuery())) - ->getQuery() - ->getResult(); + return $builder->getQuery()->getResult(); } /** From 1461e399787fcd0b230feee26d8c183f5095d5bd Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 12:20:06 +0100 Subject: [PATCH 04/16] Properly serialize task objects in task log --- CHANGELOG.md | 8 +++++ UPGRADE.md | 6 ++++ composer.json | 1 + src/Entity/TaskLog.php | 17 ++++------ src/Normalizer/TaskDetailsNormalizer.php | 41 +++++++++++++++++++++++- 5 files changed, 62 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b17787..9c8cc89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,17 @@ +3.3.0 +===== + +* (feature) Properly serialize task objects in task log. +* (deprecation) Deprecate `TaskLog::getTaskObject()`. Use `TaskDetailsNormalizer::deserializeTask()` instead. + + 3.2.4 ===== * (bug) Fix stamps passed to `TaskManager::enqueue()` being silently dropped due to `Envelope::with()` being immutable. * (improvement) Add database index on `time_queued` column of `task_manager_tasks` for faster log queries and cleanup. * (internal) Remove unused `Paginator` wrapper in `TaskLogModel::getMostRecentEntries()`. +* (security) Replace `serialize()`/`unserialize()` with Symfony Serializer in `TaskDetailsNormalizer`. Add `TaskDetailsNormalizer::deserializeTask()` as replacement for the deprecated `TaskLog::getTaskObject()`. 3.2.3 diff --git a/UPGRADE.md b/UPGRADE.md index fff9988..2a6e347 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,3 +1,9 @@ +3.x to 4.0 +========== + +* `TaskLog::getTaskObject()` was removed. Use `TaskDetailsNormalizer::deserializeTask($log)` instead. + + 1.x to 2.0 ========== diff --git a/composer.json b/composer.json index a982a9e..1dbb77a 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "symfony/lock": "^7.4 || ^8.0", "symfony/messenger": "^7.4 || ^8.0", "symfony/scheduler": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", "symfony/string": "^7.4 || ^8.0", "symfony/uid": "^7.4 || ^8.0" }, diff --git a/src/Entity/TaskLog.php b/src/Entity/TaskLog.php index a6bfec0..72949ce 100644 --- a/src/Entity/TaskLog.php +++ b/src/Entity/TaskLog.php @@ -195,18 +195,15 @@ public function getTaskLabel () : ?string } /** - * Returns the unserialized cached task object + * @deprecated Use TaskDetailsNormalizer::deserializeTask() instead. Will be removed in 4.0. + * + * @todo Remove in 4.0. */ - public function getTaskObject () : ?object + public function getTaskObject () : null { - $task = $this->getTaskDetails()["task"] ?? null; - $unserialized = \is_string($task) - ? unserialize($task) - : null; - - return \is_object($unserialized) - ? $unserialized - : null; + trigger_deprecation("21torr/task-manager", "3.2.5", "TaskLog::getTaskObject() is deprecated, use TaskDetailsNormalizer::deserializeTask() instead."); + + return null; } /** diff --git a/src/Normalizer/TaskDetailsNormalizer.php b/src/Normalizer/TaskDetailsNormalizer.php index 8f2845b..fa4e068 100644 --- a/src/Normalizer/TaskDetailsNormalizer.php +++ b/src/Normalizer/TaskDetailsNormalizer.php @@ -2,9 +2,12 @@ namespace Torr\TaskManager\Normalizer; +use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Stamp\HandledStamp; use Symfony\Component\Messenger\Stamp\ReceivedStamp; +use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerException; +use Symfony\Component\Serializer\SerializerInterface; use Torr\TaskManager\Entity\TaskLog; use Torr\TaskManager\Task\Task; @@ -13,6 +16,11 @@ */ final readonly class TaskDetailsNormalizer { + public function __construct ( + private SerializerInterface $serializer, + private LoggerInterface $logger, + ) {} + /** * @return TaskDetails */ @@ -29,9 +37,40 @@ public function normalizeTaskDetails (Envelope $envelope) : array if ($task instanceof Task) { $details["label"] = $task->getMetaData()->label; - $details["task"] = serialize($task); + $details["task"] = $this->serializer->serialize($task, "json"); } return $details; } + + /** + * Deserializes the task object stored in the given log entry. + */ + public function deserializeTask (TaskLog $log) : ?Task + { + $serialized = $log->getTaskDetails()["task"] ?? null; + + if (!\is_string($serialized)) + { + return null; + } + + try + { + $task = $this->serializer->deserialize($serialized, $log->taskClass, "json"); + + return $task instanceof Task ? $task : null; + } + catch (SerializerException $exception) + { + $this->logger->error("Failed to deserialize task of class '{taskClass}' from log entry #{logId}: {message}", [ + "taskClass" => $log->taskClass, + "logId" => $log->id, + "message" => $exception->getMessage(), + "exception" => $exception, + ]); + + return null; + } + } } From 6998cbe02b99527135c9849882b792b3524367ac Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 12:26:09 +0100 Subject: [PATCH 05/16] Use constant for serializer --- src/Normalizer/TaskDetailsNormalizer.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Normalizer/TaskDetailsNormalizer.php b/src/Normalizer/TaskDetailsNormalizer.php index fa4e068..d61ebc3 100644 --- a/src/Normalizer/TaskDetailsNormalizer.php +++ b/src/Normalizer/TaskDetailsNormalizer.php @@ -6,6 +6,7 @@ use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Stamp\HandledStamp; use Symfony\Component\Messenger\Stamp\ReceivedStamp; +use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerException; use Symfony\Component\Serializer\SerializerInterface; use Torr\TaskManager\Entity\TaskLog; @@ -37,7 +38,7 @@ public function normalizeTaskDetails (Envelope $envelope) : array if ($task instanceof Task) { $details["label"] = $task->getMetaData()->label; - $details["task"] = $this->serializer->serialize($task, "json"); + $details["task"] = $this->serializer->serialize($task, JsonEncoder::FORMAT); } return $details; @@ -57,7 +58,7 @@ public function deserializeTask (TaskLog $log) : ?Task try { - $task = $this->serializer->deserialize($serialized, $log->taskClass, "json"); + $task = $this->serializer->deserialize($serialized, $log->taskClass, JsonEncoder::FORMAT); return $task instanceof Task ? $task : null; } From d0088bad6775f1856407643c07d79bf4fac86f56 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 13:21:05 +0100 Subject: [PATCH 06/16] Reduce DB calls --- CHANGELOG.md | 1 + src/Log/LogCleaner.php | 15 ++++++--------- src/Model/TaskLogModel.php | 8 -------- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c8cc89..c4d21da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * (feature) Properly serialize task objects in task log. * (deprecation) Deprecate `TaskLog::getTaskObject()`. Use `TaskDetailsNormalizer::deserializeTask()` instead. +* (improvement) Avoid redundant `COUNT` queries in `LogCleaner` by using the count of fetched IDs directly. 3.2.4 diff --git a/src/Log/LogCleaner.php b/src/Log/LogCleaner.php index 584f9dd..a57c396 100644 --- a/src/Log/LogCleaner.php +++ b/src/Log/LogCleaner.php @@ -6,14 +6,12 @@ use Symfony\Component\Clock\ClockInterface; use Torr\TaskManager\Entity\TaskLog; use Torr\TaskManager\Entity\TaskRun; -use Torr\TaskManager\Model\TaskLogModel; final readonly class LogCleaner { public function __construct ( private int $logTtlInDays, private int $logMaxEntries, - private TaskLogModel $model, private EntityManagerInterface $entityManager, private ClockInterface $clock, ) {} @@ -23,21 +21,20 @@ public function __construct ( */ public function cleanLogEntries () : int { - $tasksBefore = $this->model->getTaskCount(); - $taskIdToDelete = $this->fetchIdsToDelete(); + if (empty($taskIdToDelete)) + { + return 0; + } + // first delete runs, as they are a foreign key on the task logs $this->deleteRuns($taskIdToDelete); // then delete tasks $this->deleteTasks($taskIdToDelete); - $tasksAfter = $this->model->getTaskCount(); - - return $tasksBefore > $tasksAfter - ? ($tasksBefore - $tasksAfter) - : 0; + return \count($taskIdToDelete); } /** diff --git a/src/Model/TaskLogModel.php b/src/Model/TaskLogModel.php index fd2167c..709930f 100644 --- a/src/Model/TaskLogModel.php +++ b/src/Model/TaskLogModel.php @@ -57,14 +57,6 @@ public function getLogForTask (Task $task) : TaskLog return $log; } - /** - * - */ - public function getTaskCount () : int - { - return $this->repository->count(); - } - /** * Returns the latest task log entries * From ce4d601c641a461ff8bffd735b6a34b0e869bfe9 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 13:22:01 +0100 Subject: [PATCH 07/16] Simplify db calls --- src/Log/LogCleaner.php | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/Log/LogCleaner.php b/src/Log/LogCleaner.php index a57c396..15e407d 100644 --- a/src/Log/LogCleaner.php +++ b/src/Log/LogCleaner.php @@ -42,27 +42,11 @@ public function cleanLogEntries () : int */ private function deleteRuns (array $taskIdsToDelete) : void { - $runIdsToDelete = $this->entityManager->createQueryBuilder() - ->select("run.id") - ->from(TaskRun::class, "run") - ->leftJoin("run.taskLog", "task") - ->andWhere("task.id IN (:taskIds)") - ->setParameter("taskIds", $taskIdsToDelete) - ->getQuery() - ->getArrayResult(); - - if (empty($runIdsToDelete)) - { - return; - } - - $runIdsToDelete = array_column($runIdsToDelete, "id"); - $this->entityManager->createQueryBuilder() ->delete() ->from(TaskRun::class, "run") - ->andWhere("run.id IN (:runIds)") - ->setParameter("runIds", $runIdsToDelete) + ->andWhere("IDENTITY(run.taskLog) IN (:taskIds)") + ->setParameter("taskIds", $taskIdsToDelete) ->getQuery() ->execute(); } From 883556c8f0a9f2fc89d0221c9bb3d9dd495726da Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 13:25:57 +0100 Subject: [PATCH 08/16] Fix chain output issues --- src/Console/ChainOutput.php | 4 +- tests/Console/ChainOutputTest.php | 80 +++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 tests/Console/ChainOutputTest.php diff --git a/src/Console/ChainOutput.php b/src/Console/ChainOutput.php index d2db651..9e7e995 100644 --- a/src/Console/ChainOutput.php +++ b/src/Console/ChainOutput.php @@ -113,8 +113,8 @@ public function isSilent() : bool #[\Override] public function setDecorated (bool $decorated) : void { - $this->bufferedOutput->setDecorated(true); - $this->consoleOutput->setDecorated(true); + $this->bufferedOutput->setDecorated($decorated); + $this->consoleOutput->setDecorated($decorated); } /** diff --git a/tests/Console/ChainOutputTest.php b/tests/Console/ChainOutputTest.php new file mode 100644 index 0000000..5ca16b6 --- /dev/null +++ b/tests/Console/ChainOutputTest.php @@ -0,0 +1,80 @@ +write("hello"); + + self::assertSame("hello", $output->getBufferedOutput()); + } + + public function testWritelnIsBuffered () : void + { + $output = new ChainOutput(); + $output->writeln("hello"); + + self::assertSame("hello\n", $output->getBufferedOutput()); + } + + public function testBufferedOutputIsConsumedOnFetch () : void + { + $output = new ChainOutput(); + $output->write("hello"); + + $output->getBufferedOutput(); + + self::assertSame("", $output->getBufferedOutput()); + } + + public function testSetAndGetVerbosity () : void + { + $output = new ChainOutput(); + $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); + + self::assertSame(OutputInterface::VERBOSITY_VERBOSE, $output->getVerbosity()); + self::assertTrue($output->isVerbose()); + self::assertFalse($output->isVeryVerbose()); + self::assertFalse($output->isDebug()); + self::assertFalse($output->isQuiet()); + } + + public function testSetDecoratedForwardsValue () : void + { + $output = new ChainOutput(decorated: true); + self::assertTrue($output->isDecorated()); + + $output->setDecorated(false); + self::assertFalse($output->isDecorated()); + + $output->setDecorated(true); + self::assertTrue($output->isDecorated()); + } + + public function testConstructorDecoratedDefault () : void + { + $output = new ChainOutput(decorated: false); + self::assertFalse($output->isDecorated()); + } + + public function testVerbosityLevels () : void + { + $output = new ChainOutput(verbosity: OutputInterface::VERBOSITY_DEBUG); + self::assertTrue($output->isDebug()); + self::assertTrue($output->isVeryVerbose()); + self::assertTrue($output->isVerbose()); + + $output->setVerbosity(OutputInterface::VERBOSITY_QUIET); + self::assertTrue($output->isQuiet()); + } +} From 3480c4fe00cdcd5a13a61b5bdc18d75073938e08 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 14:16:38 +0100 Subject: [PATCH 09/16] Update branch alias --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 1dbb77a..64e09f8 100644 --- a/composer.json +++ b/composer.json @@ -64,8 +64,8 @@ "forward-command": true }, "branch-alias": { - "2.x-dev": "2.99.x-dev", - "dev-next": "2.99.x-dev" + "3.x-dev": "3.99.x-dev", + "dev-next": "3.99.x-dev" } }, "scripts": { From d1746b46356afa01915ae3d14956db34d16aa743 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 14:21:38 +0100 Subject: [PATCH 10/16] Add tests for TaskDetailsNormalizer. --- CHANGELOG.md | 1 + .../Normalizer/TaskDetailsNormalizerTest.php | 175 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 tests/Normalizer/TaskDetailsNormalizerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d21da..11d7392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * (feature) Properly serialize task objects in task log. * (deprecation) Deprecate `TaskLog::getTaskObject()`. Use `TaskDetailsNormalizer::deserializeTask()` instead. * (improvement) Avoid redundant `COUNT` queries in `LogCleaner` by using the count of fetched IDs directly. +* (internal) Add tests for `TaskDetailsNormalizer`. 3.2.4 diff --git a/tests/Normalizer/TaskDetailsNormalizerTest.php b/tests/Normalizer/TaskDetailsNormalizerTest.php new file mode 100644 index 0000000..27b7c37 --- /dev/null +++ b/tests/Normalizer/TaskDetailsNormalizerTest.php @@ -0,0 +1,175 @@ +label); + } + }; + } + + private function createNormalizer ( + ?SerializerInterface $serializer = null, + ?LoggerInterface $logger = null, + ) : TaskDetailsNormalizer + { + return new TaskDetailsNormalizer( + $serializer ?? $this->createStub(SerializerInterface::class), + $logger ?? $this->createStub(LoggerInterface::class), + ); + } + + // endregion + + // region normalizeTaskDetails + + public function testNormalizeIncludesLabelAndSerializedTask () : void + { + $task = $this->createTask("My Label"); + $serializer = $this->createMock(SerializerInterface::class); + $serializer + ->expects(self::once()) + ->method("serialize") + ->with($task, JsonEncoder::FORMAT) + ->willReturn('{"ulid":"abc"}'); + + $details = $this->createNormalizer($serializer)->normalizeTaskDetails(new Envelope($task)); + + self::assertSame("My Label", $details["label"]); + self::assertSame('{"ulid":"abc"}', $details["task"]); + } + + public function testNormalizeExtractsTransportAndHandledBy () : void + { + $task = $this->createTask(); + $envelope = new Envelope($task, [ + new ReceivedStamp("my_transport"), + new HandledStamp("result", "App\\Handler::__invoke"), + ]); + + $details = $this->createNormalizer()->normalizeTaskDetails($envelope); + + self::assertSame("my_transport", $details["transport"]); + self::assertSame("App\\Handler::__invoke", $details["handledBy"]); + } + + public function testNormalizeWithoutStampsHasNullTransportAndHandledBy () : void + { + $task = $this->createTask(); + $details = $this->createNormalizer()->normalizeTaskDetails(new Envelope($task)); + + self::assertNull($details["transport"]); + self::assertNull($details["handledBy"]); + } + + public function testNormalizeNonTaskMessageSkipsLabelAndTask () : void + { + $message = new \stdClass(); + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects(self::never())->method("serialize"); + + $details = $this->createNormalizer($serializer)->normalizeTaskDetails(new Envelope($message)); + + self::assertArrayNotHasKey("label", $details); + self::assertArrayNotHasKey("task", $details); + } + + // endregion + + // region deserializeTask + + public function testDeserializeReturnsNullWhenNoTaskStored () : void + { + $task = $this->createTask(); + $log = new TaskLog($task); + // taskDetails is empty by default + + $result = $this->createNormalizer()->deserializeTask($log); + + self::assertNull($result); + } + + public function testDeserializeReturnsTask () : void + { + $originalTask = $this->createTask(); + $log = new TaskLog($originalTask); + $log->setTaskDetails(["task" => '{"ulid":"abc"}']); + + $serializer = $this->createMock(SerializerInterface::class); + $serializer + ->expects(self::once()) + ->method("deserialize") + ->with('{"ulid":"abc"}', $log->taskClass, JsonEncoder::FORMAT) + ->willReturn($originalTask); + + $result = $this->createNormalizer($serializer)->deserializeTask($log); + + self::assertSame($originalTask, $result); + } + + public function testDeserializeReturnsNullWhenDeserializedObjectIsNotATask () : void + { + $task = $this->createTask(); + $log = new TaskLog($task); + $log->setTaskDetails(["task" => "{}'"]); + + $serializer = $this->createStub(SerializerInterface::class); + $serializer->method("deserialize")->willReturn(new \stdClass()); + + $result = $this->createNormalizer($serializer)->deserializeTask($log); + + self::assertNull($result); + } + + public function testDeserializeLogsErrorAndReturnsNullOnSerializerException () : void + { + $task = $this->createTask(); + $log = new TaskLog($task); + $log->setTaskDetails(["task" => "invalid-json"]); + + $serializer = $this->createStub(SerializerInterface::class); + $serializer->method("deserialize")->willThrowException(new NotEncodableValueException("bad json")); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method("error"); + + $result = $this->createNormalizer($serializer, $logger)->deserializeTask($log); + + self::assertNull($result); + } + + // endregion +} From 4cf16011a8b91a1f850fd821587b154e84ce3454 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 14:33:29 +0100 Subject: [PATCH 11/16] Add tests for LogCleaner. --- CHANGELOG.md | 2 +- tests/Log/LogCleanerTest.php | 199 +++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 tests/Log/LogCleanerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 11d7392..6ac2790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ * (feature) Properly serialize task objects in task log. * (deprecation) Deprecate `TaskLog::getTaskObject()`. Use `TaskDetailsNormalizer::deserializeTask()` instead. * (improvement) Avoid redundant `COUNT` queries in `LogCleaner` by using the count of fetched IDs directly. -* (internal) Add tests for `TaskDetailsNormalizer`. +* (internal) Add tests for `TaskDetailsNormalizer` and `LogCleaner`. 3.2.4 diff --git a/tests/Log/LogCleanerTest.php b/tests/Log/LogCleanerTest.php new file mode 100644 index 0000000..dbfd08e --- /dev/null +++ b/tests/Log/LogCleanerTest.php @@ -0,0 +1,199 @@ +createStub(QueryBuilder::class); + + foreach (["select", "from", "leftJoin", "where", "andWhere", "setParameter", "addOrderBy", "setFirstResult", "setMaxResults", "delete"] as $method) + { + $qb->method($method)->willReturnSelf(); + } + + $qb->method("getQuery")->willReturn($query); + + return $qb; + } + + private function createCutoffQuery (?TaskLog $cutoffEntry) : Query + { + $query = $this->createStub(Query::class); + $query->method("getResult")->willReturn(null !== $cutoffEntry ? [$cutoffEntry] : []); + + return $query; + } + + private function createFetchQuery (array $ids) : Query + { + $rows = array_map(static fn (int $id) => ["id" => $id], $ids); + + $query = $this->createStub(Query::class); + $query->method("getArrayResult")->willReturn($rows); + + return $query; + } + + /** + * Creates a fetch QueryBuilder stub that captures the oldestTimestamp parameter. + */ + private function createCapturingFetchQb (mixed &$capturedOldestTimestamp) : QueryBuilder + { + $fetchQb = $this->createStub(QueryBuilder::class); + + foreach (["select", "from", "leftJoin", "where", "andWhere", "addOrderBy", "setFirstResult", "setMaxResults", "delete"] as $method) + { + $fetchQb->method($method)->willReturnSelf(); + } + + $fetchQb->method("setParameter") + ->willReturnCallback( + function (string $key, mixed $value) use ($fetchQb, &$capturedOldestTimestamp) : QueryBuilder + { + if ("oldestTimestamp" === $key) + { + $capturedOldestTimestamp = $value; + } + + return $fetchQb; + }, + ); + + $fetchQb->method("getQuery")->willReturn($this->createFetchQuery([])); + + return $fetchQb; + } + + // endregion + + public function testGetMaxLogEntryAge () : void + { + $cleaner = new LogCleaner(30, 100, $this->createStub(EntityManagerInterface::class), new MockClock()); + + self::assertSame(30, $cleaner->getMaxLogEntryAge()); + } + + public function testGetMaxLogEntryNumber () : void + { + $cleaner = new LogCleaner(30, 100, $this->createStub(EntityManagerInterface::class), new MockClock()); + + self::assertSame(100, $cleaner->getMaxLogEntryNumber()); + } + + public function testCleanLogEntriesReturnsZeroWhenNothingToDelete () : void + { + $em = $this->createMock(EntityManagerInterface::class); + // Only 2 QueryBuilders: getCutoffEntry + fetchIdsToDelete — no delete queries + $em->expects(self::exactly(2)) + ->method("createQueryBuilder") + ->willReturnOnConsecutiveCalls( + $this->createQueryBuilderStub($this->createCutoffQuery(null)), + $this->createQueryBuilderStub($this->createFetchQuery([])), + ); + + $cleaner = new LogCleaner(30, 100, $em, new MockClock()); + + self::assertSame(0, $cleaner->cleanLogEntries()); + } + + public function testCleanLogEntriesReturnsCountAndRunsDeletes () : void + { + $deleteQuery = $this->createStub(Query::class); + + $em = $this->createMock(EntityManagerInterface::class); + // 4 QueryBuilders: getCutoffEntry + fetchIdsToDelete + deleteRuns + deleteTasks + $em->expects(self::exactly(4)) + ->method("createQueryBuilder") + ->willReturnOnConsecutiveCalls( + $this->createQueryBuilderStub($this->createCutoffQuery(null)), + $this->createQueryBuilderStub($this->createFetchQuery([1, 2, 3])), + $this->createQueryBuilderStub($deleteQuery), + $this->createQueryBuilderStub($deleteQuery), + ); + + $cleaner = new LogCleaner(30, 100, $em, new MockClock()); + + self::assertSame(3, $cleaner->cleanLogEntries()); + } + + public function testCutoffEntryOverridesTtlWhenNewer () : void + { + // Use the real clock time as MockClock: TTL purge date = now - 30 days. + // The TaskLog created below has timeQueued ≈ now (real system clock), + // which is newer than TTL purge date, so the cutoff entry should override. + $clock = new MockClock(); + $cutoffEntry = new TaskLog($this->createTask()); + + $capturedOldestTimestamp = null; + + $em = $this->createStub(EntityManagerInterface::class); + $em->method("createQueryBuilder") + ->willReturnOnConsecutiveCalls( + $this->createQueryBuilderStub($this->createCutoffQuery($cutoffEntry)), + $this->createCapturingFetchQb($capturedOldestTimestamp), + ); + + $cleaner = new LogCleaner(30, 100, $em, $clock); + $cleaner->cleanLogEntries(); + + self::assertEquals($cutoffEntry->timeQueued, $capturedOldestTimestamp); + } + + public function testTtlPurgeDateUsedWhenNoCutoffEntry () : void + { + $clock = new MockClock("2024-06-15 12:00:00"); + $expectedPurgeDate = $clock->now()->sub(new \DateInterval("P30D")); + + $capturedOldestTimestamp = null; + + $em = $this->createStub(EntityManagerInterface::class); + $em->method("createQueryBuilder") + ->willReturnOnConsecutiveCalls( + $this->createQueryBuilderStub($this->createCutoffQuery(null)), + $this->createCapturingFetchQb($capturedOldestTimestamp), + ); + + $cleaner = new LogCleaner(30, 100, $em, $clock); + $cleaner->cleanLogEntries(); + + self::assertNotNull($capturedOldestTimestamp); + self::assertEqualsWithDelta( + $expectedPurgeDate->getTimestamp(), + $capturedOldestTimestamp->getTimestamp(), + 1, + ); + } +} From 1e7a840b693415cc3add3418794dd386be6941bb Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 14:36:15 +0100 Subject: [PATCH 12/16] Add tests for TaskRegistry and RegisterTasksEvent. --- CHANGELOG.md | 2 +- tests/Event/RegisterTasksEventTest.php | 80 +++++++++++++ tests/Registry/TaskRegistryTest.php | 160 +++++++++++++++++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 tests/Event/RegisterTasksEventTest.php create mode 100644 tests/Registry/TaskRegistryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac2790..ac7cc58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ * (feature) Properly serialize task objects in task log. * (deprecation) Deprecate `TaskLog::getTaskObject()`. Use `TaskDetailsNormalizer::deserializeTask()` instead. * (improvement) Avoid redundant `COUNT` queries in `LogCleaner` by using the count of fetched IDs directly. -* (internal) Add tests for `TaskDetailsNormalizer` and `LogCleaner`. +* (internal) Add tests for `TaskDetailsNormalizer`, `LogCleaner`, `TaskRegistry`, and `RegisterTasksEvent`. 3.2.4 diff --git a/tests/Event/RegisterTasksEventTest.php b/tests/Event/RegisterTasksEventTest.php new file mode 100644 index 0000000..ce3f1cb --- /dev/null +++ b/tests/Event/RegisterTasksEventTest.php @@ -0,0 +1,80 @@ +label, $this->group); + } + }; + } + + // endregion + + public function testRegisterAddsTask () : void + { + $task = $this->createTask("My Task"); + $event = new RegisterTasksEvent(); + $event->register($task); + + self::assertSame([$task], $event->getTasks()); + } + + public function testRegisterThrowsOnDuplicateKey () : void + { + $event = new RegisterTasksEvent(); + $event->register($this->createTask("My Task")); + + $this->expectException(DuplicateTaskRegisteredException::class); + $event->register($this->createTask("My Task")); + } + + public function testGetTasksReturnsSortedByLabel () : void + { + $event = new RegisterTasksEvent(); + $event->register($this->createTask("Zebra Task")); + $event->register($this->createTask("Alpha Task")); + $event->register($this->createTask("Middle Task")); + + $labels = array_map( + static fn (Task $t) => $t->getMetaData()->label, + $event->getTasks(), + ); + + self::assertSame(["Alpha Task", "Middle Task", "Zebra Task"], $labels); + } + + public function testRegisterIsChainable () : void + { + $event = new RegisterTasksEvent(); + $result = $event->register($this->createTask("Task A")); + + self::assertSame($event, $result); + } +} diff --git a/tests/Registry/TaskRegistryTest.php b/tests/Registry/TaskRegistryTest.php new file mode 100644 index 0000000..39f4853 --- /dev/null +++ b/tests/Registry/TaskRegistryTest.php @@ -0,0 +1,160 @@ +label, $this->group); + } + }; + } + + /** + * @param Task[] $tasks + */ + private function createRegistry (array $tasks) : TaskRegistry + { + $dispatcher = $this->createStub(EventDispatcherInterface::class); + $dispatcher->method("dispatch")->willReturnCallback( + static function (RegisterTasksEvent $event) use ($tasks) : RegisterTasksEvent + { + foreach ($tasks as $task) + { + $event->register($task); + } + + return $event; + }, + ); + + return new TaskRegistry($dispatcher); + } + + // endregion + + public function testGetAllTasksReturnsRegisteredTasks () : void + { + $task1 = $this->createTask("Task A"); + $task2 = $this->createTask("Task B"); + $registry = $this->createRegistry([$task1, $task2]); + + self::assertSame([$task1, $task2], $registry->getAllTasks()); + } + + public function testGetAllTasksReturnsSortedByLabel () : void + { + $registry = $this->createRegistry([ + $this->createTask("Zebra"), + $this->createTask("Alpha"), + $this->createTask("Middle"), + ]); + + $labels = array_map( + static fn (Task $t) => $t->getMetaData()->label, + $registry->getAllTasks(), + ); + + self::assertSame(["Alpha", "Middle", "Zebra"], $labels); + } + + public function testGetTaskByKeyReturnsTask () : void + { + $task = $this->createTask("My Task"); + $registry = $this->createRegistry([$task]); + + $key = $task->getMetaData()->getKey(); + self::assertSame($task, $registry->getTaskByKey($key)); + } + + public function testGetTaskByKeyReturnsNullWhenNotFound () : void + { + $registry = $this->createRegistry([]); + + self::assertNull($registry->getTaskByKey("non-existent-key")); + } + + public function testGetGroupedTasksSeparatesGroups () : void + { + $registry = $this->createRegistry([ + $this->createTask("Task A", "Group One"), + $this->createTask("Task B", "Group Two"), + $this->createTask("Task C", "Group One"), + ]); + + $grouped = $registry->getGroupedTasks(); + + self::assertArrayHasKey("Group One", $grouped); + self::assertArrayHasKey("Group Two", $grouped); + self::assertCount(2, $grouped["Group One"]); + self::assertCount(1, $grouped["Group Two"]); + } + + public function testGetGroupedTasksPutsUngroupedInOther () : void + { + $registry = $this->createRegistry([ + $this->createTask("Grouped Task", "My Group"), + $this->createTask("Ungrouped Task"), + ]); + + $grouped = $registry->getGroupedTasks(); + + self::assertArrayHasKey("(other)", $grouped); + self::assertCount(1, $grouped["(other)"]); + } + + public function testGetGroupedTasksSortsGroupsAlphabetically () : void + { + $registry = $this->createRegistry([ + $this->createTask("Task Z", "Zebra"), + $this->createTask("Task A", "Alpha"), + $this->createTask("Task M", "Middle"), + ]); + + $groupKeys = array_keys($registry->getGroupedTasks()); + + self::assertSame(["Alpha", "Middle", "Zebra"], $groupKeys); + } + + public function testDispatcherCalledOnlyOnce () : void + { + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher->expects(self::once()) + ->method("dispatch") + ->willReturnCallback(static fn (RegisterTasksEvent $event) : RegisterTasksEvent => $event); + + $registry = new TaskRegistry($dispatcher); + + // Call multiple methods — dispatcher must only be invoked once + $registry->getAllTasks(); + $registry->getAllTasks(); + $registry->getTaskByKey("any"); + $registry->getGroupedTasks(); + } +} From 85a62bccff79908a92e7718a70cb90d7f9a3b03d Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 14:39:31 +0100 Subject: [PATCH 13/16] Add tests for TaskManager::enqueue(). --- CHANGELOG.md | 2 +- tests/Manager/TaskManagerTest.php | 172 ++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 tests/Manager/TaskManagerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ac7cc58..8e1b710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ * (feature) Properly serialize task objects in task log. * (deprecation) Deprecate `TaskLog::getTaskObject()`. Use `TaskDetailsNormalizer::deserializeTask()` instead. * (improvement) Avoid redundant `COUNT` queries in `LogCleaner` by using the count of fetched IDs directly. -* (internal) Add tests for `TaskDetailsNormalizer`, `LogCleaner`, `TaskRegistry`, and `RegisterTasksEvent`. +* (internal) Add tests for `TaskDetailsNormalizer`, `LogCleaner`, `TaskRegistry`, `RegisterTasksEvent`, and `TaskManager`. 3.2.4 diff --git a/tests/Manager/TaskManagerTest.php b/tests/Manager/TaskManagerTest.php new file mode 100644 index 0000000..62f9136 --- /dev/null +++ b/tests/Manager/TaskManagerTest.php @@ -0,0 +1,172 @@ +uniqueTaskId); + } + }; + } + + /** + * Creates a listable transport backed by the given envelopes. + * + * @param Envelope[] $envelopes + */ + private function createListableTransport (array $envelopes = []) : TransportInterface&ListableReceiverInterface + { + return new class($envelopes) implements TransportInterface, ListableReceiverInterface { + /** @param Envelope[] $envelopes */ + public function __construct (private readonly array $envelopes) {} + + public function all (?int $limit = null) : iterable { return $this->envelopes; } + public function find (mixed $id) : Envelope { throw new \RuntimeException("Not implemented"); } + public function get () : iterable { return []; } + public function ack (Envelope $envelope) : void {} + public function reject (Envelope $envelope) : void {} + public function send (Envelope $envelope) : Envelope { return $envelope; } + }; + } + + /** + * @param array $transports + */ + private function createManager (array $transports, MessageBusInterface $bus) : TaskManager + { + $factories = []; + + foreach ($transports as $name => $transport) + { + $factories[$name] = static fn () => $transport; + } + + $locator = new ServiceLocator($factories); + $config = new BundleConfig(sortedQueues: array_keys($transports)); + $helper = new TransportsHelper($locator, $config); + + return new TaskManager($helper, $bus); + } + + // endregion + + public function testEnqueueWithoutUniqueTaskIdAlwaysDispatches () : void + { + $bus = $this->createMock(MessageBusInterface::class); + $bus->expects(self::once())->method("dispatch")->willReturnArgument(0); + + $manager = $this->createManager(["queue" => $this->createListableTransport()], $bus); + $task = $this->createTask(null); + + self::assertTrue($manager->enqueue($task)); + } + + public function testEnqueueReturnsTrueWhenNoConflict () : void + { + $bus = $this->createStub(MessageBusInterface::class); + $bus->method("dispatch")->willReturnArgument(0); + + $manager = $this->createManager(["queue" => $this->createListableTransport()], $bus); + + self::assertTrue($manager->enqueue($this->createTask("test.task"))); + } + + public function testEnqueueReturnsFalseWhenDuplicateInQueue () : void + { + $existingTask = $this->createTask("test.task"); + $transport = $this->createListableTransport([new Envelope($existingTask)]); + + $bus = $this->createMock(MessageBusInterface::class); + $bus->expects(self::never())->method("dispatch"); + + $manager = $this->createManager(["queue" => $transport], $bus); + $newTask = $this->createTask("test.task"); + + self::assertFalse($manager->enqueue($newTask)); + } + + public function testEnqueueScansMultipleQueuesForDuplicate () : void + { + $existingTask = $this->createTask("test.task"); + $emptyTransport = $this->createListableTransport([]); + $fullTransport = $this->createListableTransport([new Envelope($existingTask)]); + + $bus = $this->createMock(MessageBusInterface::class); + $bus->expects(self::never())->method("dispatch"); + + $manager = $this->createManager([ + "queue_a" => $emptyTransport, + "queue_b" => $fullTransport, + ], $bus); + + self::assertFalse($manager->enqueue($this->createTask("test.task"))); + } + + public function testEnqueueDispatchesWhenDifferentUniqueTaskIdInQueue () : void + { + $otherTask = $this->createTask("other.task"); + $transport = $this->createListableTransport([new Envelope($otherTask)]); + + $bus = $this->createMock(MessageBusInterface::class); + $bus->expects(self::once())->method("dispatch")->willReturnArgument(0); + + $manager = $this->createManager(["queue" => $transport], $bus); + + self::assertTrue($manager->enqueue($this->createTask("test.task"))); + } + + public function testEnqueueForwardsStampsToDispatchedEnvelope () : void + { + $capturedEnvelope = null; + + $bus = $this->createStub(MessageBusInterface::class); + $bus->method("dispatch")->willReturnCallback( + static function (Envelope $envelope) use (&$capturedEnvelope) : Envelope + { + $capturedEnvelope = $envelope; + + return $envelope; + }, + ); + + $stamp = new class() implements StampInterface {}; + + $manager = $this->createManager(["queue" => $this->createListableTransport()], $bus); + $manager->enqueue($this->createTask(null), [$stamp]); + + self::assertNotNull($capturedEnvelope); + self::assertNotEmpty($capturedEnvelope->all($stamp::class)); + } +} From a54aa2d4e748eb949c55f9a38bc61c84fd6b73cb Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 14:47:05 +0100 Subject: [PATCH 14/16] Add tests for TaskLog and TaskRun. --- CHANGELOG.md | 2 +- tests/Entity/TaskLogTest.php | 197 +++++++++++++++++++++++++++++++++++ tests/Entity/TaskRunTest.php | 109 +++++++++++++++++++ 3 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 tests/Entity/TaskLogTest.php create mode 100644 tests/Entity/TaskRunTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e1b710..cc643bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ * (feature) Properly serialize task objects in task log. * (deprecation) Deprecate `TaskLog::getTaskObject()`. Use `TaskDetailsNormalizer::deserializeTask()` instead. * (improvement) Avoid redundant `COUNT` queries in `LogCleaner` by using the count of fetched IDs directly. -* (internal) Add tests for `TaskDetailsNormalizer`, `LogCleaner`, `TaskRegistry`, `RegisterTasksEvent`, and `TaskManager`. +* (internal) Add tests for `TaskDetailsNormalizer`, `LogCleaner`, `TaskRegistry`, `RegisterTasksEvent`, `TaskManager`, `TaskLog`, and `TaskRun`. 3.2.4 diff --git a/tests/Entity/TaskLogTest.php b/tests/Entity/TaskLogTest.php new file mode 100644 index 0000000..b9387f6 --- /dev/null +++ b/tests/Entity/TaskLogTest.php @@ -0,0 +1,197 @@ +createTask()); + } + + // endregion + + public function testInitialStateHasNoRuns () : void + { + $log = $this->createLog(); + + self::assertTrue($log->runs->isEmpty()); + self::assertFalse($log->isSuccess()); + self::assertNull($log->getStatus()); + self::assertNull($log->getLastUnfinishedRun()); + self::assertTrue($log->isFinished()); + } + + public function testCreateRunAddsRun () : void + { + $log = $this->createLog(); + $run = $log->createRun(); + + self::assertCount(1, $log->runs); + self::assertSame($run, $log->runs->first()); + } + + public function testIsFinishedReturnsFalseWithUnfinishedRun () : void + { + $log = $this->createLog(); + $log->createRun(); + + self::assertFalse($log->isFinished()); + self::assertNotNull($log->getLastUnfinishedRun()); + } + + public function testIsFinishedReturnsTrueAfterRunFinishes () : void + { + $log = $this->createLog(); + $run = $log->createRun(); + $run->finish(true, null); + + self::assertTrue($log->isFinished()); + self::assertNull($log->getLastUnfinishedRun()); + } + + public function testIsSuccessReturnsTrueAfterSuccessfulRun () : void + { + $log = $this->createLog(); + $run = $log->createRun(); + $run->finish(true, null); + + self::assertTrue($log->isSuccess()); + } + + public function testIsSuccessReturnsFalseAfterFailedRun () : void + { + $log = $this->createLog(); + $run = $log->createRun(); + $run->finish(false, null); + + self::assertFalse($log->isSuccess()); + } + + public function testGetStatusNullWithNoFinishedRuns () : void + { + $log = $this->createLog(); + $log->createRun(); // unfinished + + self::assertNull($log->getStatus()); + } + + public function testGetStatusTrueAfterSuccess () : void + { + $log = $this->createLog(); + $run = $log->createRun(); + $run->finish(true, null); + + self::assertTrue($log->getStatus()); + } + + public function testGetStatusFalseAfterFailure () : void + { + $log = $this->createLog(); + $run = $log->createRun(); + $run->finish(false, null); + + self::assertFalse($log->getStatus()); + } + + public function testGetStatusReturnsTrueOnceAnyRunSucceeds () : void + { + $log = $this->createLog(); + $run1 = $log->createRun(); + $run1->finish(false, null); + + // second run succeeds — status should be true + $run2 = $log->createRun(); + $run2->finish(true, null); + + self::assertTrue($log->getStatus()); + } + + public function testCreateRunThrowsWhenAlreadySuccessful () : void + { + $log = $this->createLog(); + $run = $log->createRun(); + $run->finish(true, null); + + $this->expectException(InvalidLogActionException::class); + $log->createRun(); + } + + public function testGetTotalDurationSumsAllRuns () : void + { + $log = $this->createLog(); + + $run1 = $log->createRun(); + $run1->finish(false, null); + + $run2 = $log->createRun(); + $run2->finish(true, null); + + $total = $log->getTotalDuration(); + + self::assertGreaterThan(0, $total); + self::assertEqualsWithDelta( + ($run1->duration ?? 0) + ($run2->duration ?? 0), + $total, + 0.001, + ); + } + + public function testTaskDetailsAccessors () : void + { + $log = $this->createLog(); + $log->setTaskDetails(["label" => "My Label", "transport" => "async", "handledBy" => "MyHandler"]); + + self::assertSame("My Label", $log->getTaskLabel()); + self::assertSame("async", $log->getTransport()); + self::assertSame("MyHandler", $log->getHandledBy()); + } + + public function testTaskDetailsDefaultsToNull () : void + { + $log = $this->createLog(); + + self::assertNull($log->getTaskLabel()); + self::assertNull($log->getTransport()); + self::assertNull($log->getHandledBy()); + } + + public function testTaskIdMatchesTaskUlid () : void + { + $task = $this->createTask(); + $log = new TaskLog($task); + + self::assertSame($task->ulid, $log->taskId); + } + + public function testTaskClassMatchesTaskClass () : void + { + $task = $this->createTask(); + $log = new TaskLog($task); + + self::assertSame($task::class, $log->taskClass); + } +} diff --git a/tests/Entity/TaskRunTest.php b/tests/Entity/TaskRunTest.php new file mode 100644 index 0000000..2a008d0 --- /dev/null +++ b/tests/Entity/TaskRunTest.php @@ -0,0 +1,109 @@ +createLog()); + + self::assertFalse($run->isFinished); + self::assertNull($run->success); + self::assertNull($run->duration); + self::assertNull($run->output); + self::assertNull($run->finishedProperly); + } + + public function testFinishMarksAsSuccessful () : void + { + $run = new TaskRun($this->createLog()); + $run->finish(true, "some output"); + + self::assertTrue($run->isFinished); + self::assertTrue($run->success); + self::assertSame("some output", $run->output); + self::assertTrue($run->finishedProperly); + self::assertGreaterThan(0, $run->duration); + } + + public function testFinishMarksAsFailure () : void + { + $run = new TaskRun($this->createLog()); + $run->finish(false, null); + + self::assertTrue($run->isFinished); + self::assertFalse($run->success); + self::assertNull($run->output); + self::assertTrue($run->finishedProperly); + } + + public function testAbortMarksAsNotFinishedProperly () : void + { + $run = new TaskRun($this->createLog()); + $run->abort(true, "aborted output"); + + self::assertTrue($run->isFinished); + self::assertTrue($run->success); + self::assertSame("aborted output", $run->output); + self::assertFalse($run->finishedProperly); + } + + public function testDoubleFinalizationIsIgnored () : void + { + $run = new TaskRun($this->createLog()); + $run->finish(true, "first"); + $run->finish(false, "second"); + + // second call must be ignored + self::assertTrue($run->success); + self::assertSame("first", $run->output); + } + + public function testDoubleFinalizationLogsError () : void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method("error"); + + $run = new TaskRun($this->createLog(), $logger); + $run->finish(true, null); + $run->finish(false, null); + } + + public function testHasFinishedProperly () : void + { + $run = new TaskRun($this->createLog()); + self::assertFalse($run->hasFinishedProperly()); + + $run->finish(true, null); + self::assertTrue($run->hasFinishedProperly()); + } +} From c6737689fa3e3b50f3db21efe43c6b1faac425cf Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 15:52:01 +0100 Subject: [PATCH 15/16] Make main output configurable --- src/Console/ChainOutput.php | 23 ++++++++++++++++------- tests/Console/ChainOutputTest.php | 9 +++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/Console/ChainOutput.php b/src/Console/ChainOutput.php index 9e7e995..e3c5739 100644 --- a/src/Console/ChainOutput.php +++ b/src/Console/ChainOutput.php @@ -9,7 +9,7 @@ final readonly class ChainOutput implements OutputInterface { - private ConsoleOutput $consoleOutput; + private OutputInterface $mainOutput; private BufferedOutput $bufferedOutput; /** @@ -18,10 +18,19 @@ public function __construct ( int $verbosity = self::VERBOSITY_NORMAL, bool $decorated = true, ?OutputFormatterInterface $formatter = null, + ?OutputInterface $mainOutput = null, ) { $this->bufferedOutput = new BufferedOutput($verbosity, $decorated, $formatter); - $this->consoleOutput = new ConsoleOutput($verbosity, $decorated, $formatter); + + $this->mainOutput = $mainOutput ?? new ConsoleOutput(); + $this->mainOutput->setDecorated($decorated); + $this->mainOutput->setVerbosity($verbosity); + + if (null !== $formatter) + { + $this->mainOutput->setFormatter($formatter); + } } /** @@ -31,7 +40,7 @@ public function __construct ( public function write (iterable|string $messages, bool $newline = false, int $options = 0) : void { $this->bufferedOutput->write($messages, $newline, $options); - $this->consoleOutput->write($messages, $newline, $options); + $this->mainOutput->write($messages, $newline, $options); } /** @@ -41,7 +50,7 @@ public function write (iterable|string $messages, bool $newline = false, int $op public function writeln (iterable|string $messages, int $options = 0) : void { $this->bufferedOutput->writeln($messages, $options); - $this->consoleOutput->writeln($messages, $options); + $this->mainOutput->writeln($messages, $options); } /** @@ -51,7 +60,7 @@ public function writeln (iterable|string $messages, int $options = 0) : void public function setVerbosity (int $level) : void { $this->bufferedOutput->setVerbosity($level); - $this->consoleOutput->setVerbosity($level); + $this->mainOutput->setVerbosity($level); } /** @@ -114,7 +123,7 @@ public function isSilent() : bool public function setDecorated (bool $decorated) : void { $this->bufferedOutput->setDecorated($decorated); - $this->consoleOutput->setDecorated($decorated); + $this->mainOutput->setDecorated($decorated); } /** @@ -132,7 +141,7 @@ public function isDecorated () : bool public function setFormatter (OutputFormatterInterface $formatter) : void { $this->bufferedOutput->setFormatter($formatter); - $this->consoleOutput->setFormatter($formatter); + $this->mainOutput->setFormatter($formatter); } /** diff --git a/tests/Console/ChainOutputTest.php b/tests/Console/ChainOutputTest.php index 5ca16b6..e2a2164 100644 --- a/tests/Console/ChainOutputTest.php +++ b/tests/Console/ChainOutputTest.php @@ -3,6 +3,7 @@ namespace Tests\Torr\TaskManager\Console; use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; use Torr\TaskManager\Console\ChainOutput; @@ -13,7 +14,7 @@ final class ChainOutputTest extends TestCase { public function testWriteIsBuffered () : void { - $output = new ChainOutput(); + $output = new ChainOutput(mainOutput: new NullOutput()); $output->write("hello"); self::assertSame("hello", $output->getBufferedOutput()); @@ -21,7 +22,7 @@ public function testWriteIsBuffered () : void public function testWritelnIsBuffered () : void { - $output = new ChainOutput(); + $output = new ChainOutput(mainOutput: new NullOutput()); $output->writeln("hello"); self::assertSame("hello\n", $output->getBufferedOutput()); @@ -29,7 +30,7 @@ public function testWritelnIsBuffered () : void public function testBufferedOutputIsConsumedOnFetch () : void { - $output = new ChainOutput(); + $output = new ChainOutput(mainOutput: new NullOutput()); $output->write("hello"); $output->getBufferedOutput(); @@ -39,7 +40,7 @@ public function testBufferedOutputIsConsumedOnFetch () : void public function testSetAndGetVerbosity () : void { - $output = new ChainOutput(); + $output = new ChainOutput(mainOutput: new NullOutput()); $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); self::assertSame(OutputInterface::VERBOSITY_VERBOSE, $output->getVerbosity()); From f543f0e050a8714756b0b2e21c7c9d7b2fce2023 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Wed, 18 Mar 2026 15:52:09 +0100 Subject: [PATCH 16/16] Fix CS --- tests/Log/LogCleanerTest.php | 20 +++++++++---------- tests/Manager/TaskManagerTest.php | 13 +++++++++--- .../Normalizer/TaskDetailsNormalizerTest.php | 8 ++++---- tests/Registry/TaskRegistryTest.php | 2 +- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/tests/Log/LogCleanerTest.php b/tests/Log/LogCleanerTest.php index dbfd08e..51a3f44 100644 --- a/tests/Log/LogCleanerTest.php +++ b/tests/Log/LogCleanerTest.php @@ -37,7 +37,7 @@ public function getMetaData () : TaskMetaData */ private function createQueryBuilderStub (Query $query) : QueryBuilder { - $qb = $this->createStub(QueryBuilder::class); + $qb = self::createStub(QueryBuilder::class); foreach (["select", "from", "leftJoin", "where", "andWhere", "setParameter", "addOrderBy", "setFirstResult", "setMaxResults", "delete"] as $method) { @@ -51,7 +51,7 @@ private function createQueryBuilderStub (Query $query) : QueryBuilder private function createCutoffQuery (?TaskLog $cutoffEntry) : Query { - $query = $this->createStub(Query::class); + $query = self::createStub(Query::class); $query->method("getResult")->willReturn(null !== $cutoffEntry ? [$cutoffEntry] : []); return $query; @@ -61,7 +61,7 @@ private function createFetchQuery (array $ids) : Query { $rows = array_map(static fn (int $id) => ["id" => $id], $ids); - $query = $this->createStub(Query::class); + $query = self::createStub(Query::class); $query->method("getArrayResult")->willReturn($rows); return $query; @@ -72,7 +72,7 @@ private function createFetchQuery (array $ids) : Query */ private function createCapturingFetchQb (mixed &$capturedOldestTimestamp) : QueryBuilder { - $fetchQb = $this->createStub(QueryBuilder::class); + $fetchQb = self::createStub(QueryBuilder::class); foreach (["select", "from", "leftJoin", "where", "andWhere", "addOrderBy", "setFirstResult", "setMaxResults", "delete"] as $method) { @@ -81,7 +81,7 @@ private function createCapturingFetchQb (mixed &$capturedOldestTimestamp) : Quer $fetchQb->method("setParameter") ->willReturnCallback( - function (string $key, mixed $value) use ($fetchQb, &$capturedOldestTimestamp) : QueryBuilder + static function (string $key, mixed $value) use ($fetchQb, &$capturedOldestTimestamp) : QueryBuilder { if ("oldestTimestamp" === $key) { @@ -101,14 +101,14 @@ function (string $key, mixed $value) use ($fetchQb, &$capturedOldestTimestamp) : public function testGetMaxLogEntryAge () : void { - $cleaner = new LogCleaner(30, 100, $this->createStub(EntityManagerInterface::class), new MockClock()); + $cleaner = new LogCleaner(30, 100, self::createStub(EntityManagerInterface::class), new MockClock()); self::assertSame(30, $cleaner->getMaxLogEntryAge()); } public function testGetMaxLogEntryNumber () : void { - $cleaner = new LogCleaner(30, 100, $this->createStub(EntityManagerInterface::class), new MockClock()); + $cleaner = new LogCleaner(30, 100, self::createStub(EntityManagerInterface::class), new MockClock()); self::assertSame(100, $cleaner->getMaxLogEntryNumber()); } @@ -131,7 +131,7 @@ public function testCleanLogEntriesReturnsZeroWhenNothingToDelete () : void public function testCleanLogEntriesReturnsCountAndRunsDeletes () : void { - $deleteQuery = $this->createStub(Query::class); + $deleteQuery = self::createStub(Query::class); $em = $this->createMock(EntityManagerInterface::class); // 4 QueryBuilders: getCutoffEntry + fetchIdsToDelete + deleteRuns + deleteTasks @@ -159,7 +159,7 @@ public function testCutoffEntryOverridesTtlWhenNewer () : void $capturedOldestTimestamp = null; - $em = $this->createStub(EntityManagerInterface::class); + $em = self::createStub(EntityManagerInterface::class); $em->method("createQueryBuilder") ->willReturnOnConsecutiveCalls( $this->createQueryBuilderStub($this->createCutoffQuery($cutoffEntry)), @@ -179,7 +179,7 @@ public function testTtlPurgeDateUsedWhenNoCutoffEntry () : void $capturedOldestTimestamp = null; - $em = $this->createStub(EntityManagerInterface::class); + $em = self::createStub(EntityManagerInterface::class); $em->method("createQueryBuilder") ->willReturnOnConsecutiveCalls( $this->createQueryBuilderStub($this->createCutoffQuery(null)), diff --git a/tests/Manager/TaskManagerTest.php b/tests/Manager/TaskManagerTest.php index 62f9136..9606e34 100644 --- a/tests/Manager/TaskManagerTest.php +++ b/tests/Manager/TaskManagerTest.php @@ -50,13 +50,20 @@ private function createListableTransport (array $envelopes = []) : TransportInte { return new class($envelopes) implements TransportInterface, ListableReceiverInterface { /** @param Envelope[] $envelopes */ - public function __construct (private readonly array $envelopes) {} + public function __construct ( + private readonly array $envelopes, + ) {} public function all (?int $limit = null) : iterable { return $this->envelopes; } + public function find (mixed $id) : Envelope { throw new \RuntimeException("Not implemented"); } + public function get () : iterable { return []; } + public function ack (Envelope $envelope) : void {} + public function reject (Envelope $envelope) : void {} + public function send (Envelope $envelope) : Envelope { return $envelope; } }; } @@ -95,7 +102,7 @@ public function testEnqueueWithoutUniqueTaskIdAlwaysDispatches () : void public function testEnqueueReturnsTrueWhenNoConflict () : void { - $bus = $this->createStub(MessageBusInterface::class); + $bus = self::createStub(MessageBusInterface::class); $bus->method("dispatch")->willReturnArgument(0); $manager = $this->createManager(["queue" => $this->createListableTransport()], $bus); @@ -151,7 +158,7 @@ public function testEnqueueForwardsStampsToDispatchedEnvelope () : void { $capturedEnvelope = null; - $bus = $this->createStub(MessageBusInterface::class); + $bus = self::createStub(MessageBusInterface::class); $bus->method("dispatch")->willReturnCallback( static function (Envelope $envelope) use (&$capturedEnvelope) : Envelope { diff --git a/tests/Normalizer/TaskDetailsNormalizerTest.php b/tests/Normalizer/TaskDetailsNormalizerTest.php index 27b7c37..45dc719 100644 --- a/tests/Normalizer/TaskDetailsNormalizerTest.php +++ b/tests/Normalizer/TaskDetailsNormalizerTest.php @@ -47,8 +47,8 @@ private function createNormalizer ( ) : TaskDetailsNormalizer { return new TaskDetailsNormalizer( - $serializer ?? $this->createStub(SerializerInterface::class), - $logger ?? $this->createStub(LoggerInterface::class), + $serializer ?? self::createStub(SerializerInterface::class), + $logger ?? self::createStub(LoggerInterface::class), ); } @@ -146,7 +146,7 @@ public function testDeserializeReturnsNullWhenDeserializedObjectIsNotATask () : $log = new TaskLog($task); $log->setTaskDetails(["task" => "{}'"]); - $serializer = $this->createStub(SerializerInterface::class); + $serializer = self::createStub(SerializerInterface::class); $serializer->method("deserialize")->willReturn(new \stdClass()); $result = $this->createNormalizer($serializer)->deserializeTask($log); @@ -160,7 +160,7 @@ public function testDeserializeLogsErrorAndReturnsNullOnSerializerException () : $log = new TaskLog($task); $log->setTaskDetails(["task" => "invalid-json"]); - $serializer = $this->createStub(SerializerInterface::class); + $serializer = self::createStub(SerializerInterface::class); $serializer->method("deserialize")->willThrowException(new NotEncodableValueException("bad json")); $logger = $this->createMock(LoggerInterface::class); diff --git a/tests/Registry/TaskRegistryTest.php b/tests/Registry/TaskRegistryTest.php index 39f4853..bbe1524 100644 --- a/tests/Registry/TaskRegistryTest.php +++ b/tests/Registry/TaskRegistryTest.php @@ -41,7 +41,7 @@ public function getMetaData () : TaskMetaData */ private function createRegistry (array $tasks) : TaskRegistry { - $dispatcher = $this->createStub(EventDispatcherInterface::class); + $dispatcher = self::createStub(EventDispatcherInterface::class); $dispatcher->method("dispatch")->willReturnCallback( static function (RegisterTasksEvent $event) use ($tasks) : RegisterTasksEvent {