diff --git a/CHANGELOG.md b/CHANGELOG.md index 52efe79..cc643bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +3.3.0 +===== + +* (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`, `TaskManager`, `TaskLog`, and `TaskRun`. + + +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..64e09f8 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" }, @@ -63,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": { diff --git a/src/Console/ChainOutput.php b/src/Console/ChainOutput.php index d2db651..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); } /** @@ -113,8 +122,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->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/src/Entity/TaskLog.php b/src/Entity/TaskLog.php index e7bbfb4..72949ce 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 { /** @@ -194,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/Log/LogCleaner.php b/src/Log/LogCleaner.php index 584f9dd..15e407d 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); } /** @@ -45,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(); } 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); diff --git a/src/Model/TaskLogModel.php b/src/Model/TaskLogModel.php index 6876434..709930f 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; @@ -58,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 * @@ -92,9 +83,7 @@ public function getMostRecentEntries ( } /** @var TaskLog[] */ - return (new Paginator($builder->getQuery())) - ->getQuery() - ->getResult(); + return $builder->getQuery()->getResult(); } /** diff --git a/src/Normalizer/TaskDetailsNormalizer.php b/src/Normalizer/TaskDetailsNormalizer.php index 8f2845b..d61ebc3 100644 --- a/src/Normalizer/TaskDetailsNormalizer.php +++ b/src/Normalizer/TaskDetailsNormalizer.php @@ -2,9 +2,13 @@ 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\Encoder\JsonEncoder; +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 +17,11 @@ */ final readonly class TaskDetailsNormalizer { + public function __construct ( + private SerializerInterface $serializer, + private LoggerInterface $logger, + ) {} + /** * @return TaskDetails */ @@ -29,9 +38,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, JsonEncoder::FORMAT); } 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, JsonEncoder::FORMAT); + + 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; + } + } } diff --git a/tests/Console/ChainOutputTest.php b/tests/Console/ChainOutputTest.php new file mode 100644 index 0000000..e2a2164 --- /dev/null +++ b/tests/Console/ChainOutputTest.php @@ -0,0 +1,81 @@ +write("hello"); + + self::assertSame("hello", $output->getBufferedOutput()); + } + + public function testWritelnIsBuffered () : void + { + $output = new ChainOutput(mainOutput: new NullOutput()); + $output->writeln("hello"); + + self::assertSame("hello\n", $output->getBufferedOutput()); + } + + public function testBufferedOutputIsConsumedOnFetch () : void + { + $output = new ChainOutput(mainOutput: new NullOutput()); + $output->write("hello"); + + $output->getBufferedOutput(); + + self::assertSame("", $output->getBufferedOutput()); + } + + public function testSetAndGetVerbosity () : void + { + $output = new ChainOutput(mainOutput: new NullOutput()); + $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()); + } +} 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()); + } +} 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/Log/LogCleanerTest.php b/tests/Log/LogCleanerTest.php new file mode 100644 index 0000000..51a3f44 --- /dev/null +++ b/tests/Log/LogCleanerTest.php @@ -0,0 +1,199 @@ +method($method)->willReturnSelf(); + } + + $qb->method("getQuery")->willReturn($query); + + return $qb; + } + + private function createCutoffQuery (?TaskLog $cutoffEntry) : Query + { + $query = self::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 = self::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 = self::createStub(QueryBuilder::class); + + foreach (["select", "from", "leftJoin", "where", "andWhere", "addOrderBy", "setFirstResult", "setMaxResults", "delete"] as $method) + { + $fetchQb->method($method)->willReturnSelf(); + } + + $fetchQb->method("setParameter") + ->willReturnCallback( + static 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, self::createStub(EntityManagerInterface::class), new MockClock()); + + self::assertSame(30, $cleaner->getMaxLogEntryAge()); + } + + public function testGetMaxLogEntryNumber () : void + { + $cleaner = new LogCleaner(30, 100, self::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 = self::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 = self::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 = self::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, + ); + } +} diff --git a/tests/Manager/TaskManagerTest.php b/tests/Manager/TaskManagerTest.php new file mode 100644 index 0000000..9606e34 --- /dev/null +++ b/tests/Manager/TaskManagerTest.php @@ -0,0 +1,179 @@ +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 = self::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 = self::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)); + } +} diff --git a/tests/Normalizer/TaskDetailsNormalizerTest.php b/tests/Normalizer/TaskDetailsNormalizerTest.php new file mode 100644 index 0000000..45dc719 --- /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 ?? self::createStub(SerializerInterface::class), + $logger ?? self::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 = self::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 = self::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 +} diff --git a/tests/Registry/TaskRegistryTest.php b/tests/Registry/TaskRegistryTest.php new file mode 100644 index 0000000..bbe1524 --- /dev/null +++ b/tests/Registry/TaskRegistryTest.php @@ -0,0 +1,160 @@ +label, $this->group); + } + }; + } + + /** + * @param Task[] $tasks + */ + private function createRegistry (array $tasks) : TaskRegistry + { + $dispatcher = self::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(); + } +}