From 2a12e6e4f68c7d1c31efc1f318262a3c7a0d5f91 Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Fri, 2 Oct 2026 11:28:45 +0200 Subject: [PATCH] chore(tests) #38 Add missing tests: DatabaseUpdaterTask, AbstractDoctrineQueryTask query builder, readers, bundle and DI extension Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 + tests/CleverAgeDoctrineProcessBundleTest.php | 30 ++++ .../CleverAgeDoctrineProcessExtensionTest.php | 85 ++++++++++ .../Database/DatabaseReaderTaskSqliteTest.php | 24 ++- .../DatabaseUpdaterTaskSqliteTest.php | 154 ++++++++++++++++++ .../AbstractDoctrineQueryTaskTest.php | 51 ++++++ .../EntityManager/DoctrineReaderTaskTest.php | 9 + 7 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 tests/CleverAgeDoctrineProcessBundleTest.php create mode 100644 tests/DependencyInjection/CleverAgeDoctrineProcessExtensionTest.php create mode 100644 tests/Task/Database/DatabaseUpdaterTaskSqliteTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 44247c8..b5f9145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ Latest ------ +### Changes +* [#38](https://github.com/cleverage/doctrine-process-bundle/issues/38) Add missing tests: DatabaseUpdaterTask on a SQLite database (options, params, connection), AbstractDoctrineQueryTask query builder (criteria, order, limit, offset), readers `next()` before execution, bundle and DI extension. + ### Fixes * [#32](https://github.com/cleverage/doctrine-process-bundle/issues/32) Fix EntityManager tasks: use the entity manager given by the `entity_manager` option (it was ignored by every task except ClearEntityManagerTask), the one managing the entity class otherwise. Update documentation, add tests. * [#33](https://github.com/cleverage/doctrine-process-bundle/issues/33) Fix DatabaseReaderTask and DoctrineReaderTask: execute the query again for each input (the input following a complete iteration was skipped). Update documentation, add tests. diff --git a/tests/CleverAgeDoctrineProcessBundleTest.php b/tests/CleverAgeDoctrineProcessBundleTest.php new file mode 100644 index 0000000..bcbb077 --- /dev/null +++ b/tests/CleverAgeDoctrineProcessBundleTest.php @@ -0,0 +1,30 @@ +getPath(); + + self::assertSame(\dirname(__DIR__), $path); + self::assertDirectoryExists($path.'/config/services'); + } +} diff --git a/tests/DependencyInjection/CleverAgeDoctrineProcessExtensionTest.php b/tests/DependencyInjection/CleverAgeDoctrineProcessExtensionTest.php new file mode 100644 index 0000000..b3034d7 --- /dev/null +++ b/tests/DependencyInjection/CleverAgeDoctrineProcessExtensionTest.php @@ -0,0 +1,85 @@ + + */ + public static function provideTasks(): iterable + { + yield 'database_reader' => ['cleverage_doctrine_process.task.database_reader', DatabaseReaderTask::class]; + yield 'database_updater' => ['cleverage_doctrine_process.task.database_updater', DatabaseUpdaterTask::class]; + yield 'doctrine_clear_entity_manager' => ['cleverage_doctrine_process.task.doctrine_clear_entity_manager', ClearEntityManagerTask::class]; + yield 'doctrine_batch_writer' => ['cleverage_doctrine_process.task.doctrine_batch_writer', DoctrineBatchWriterTask::class]; + yield 'doctrine_cleaner' => ['cleverage_doctrine_process.task.doctrine_cleaner', DoctrineCleanerTask::class]; + yield 'doctrine_detacher' => ['cleverage_doctrine_process.task.doctrine_detacher', DoctrineDetacherTask::class]; + yield 'doctrine_reader' => ['cleverage_doctrine_process.task.doctrine_reader', DoctrineReaderTask::class]; + yield 'doctrine_refresher' => ['cleverage_doctrine_process.task.doctrine_refresher', DoctrineRefresherTask::class]; + yield 'doctrine_remover' => ['cleverage_doctrine_process.task.doctrine_remover', DoctrineRemoverTask::class]; + yield 'doctrine_writer' => ['cleverage_doctrine_process.task.doctrine_writer', DoctrineWriterTask::class]; + } + + /** + * @param class-string $class + */ + #[DataProvider('provideTasks')] + public function testTaskIsRegistered(string $id, string $class): void + { + $container = new ContainerBuilder(); + (new CleverAgeDoctrineProcessExtension())->load([], $container); + + $definition = $container->getDefinition($id); + self::assertSame($class, $definition->getClass()); + // Tasks are stateful: each process execution must get its own instance + self::assertFalse($definition->isShared()); + // Logged in the process tasks channel + self::assertSame([['channel' => 'cleverage_process_task']], $definition->getTag('monolog.logger')); + + // Referenced as '@' in process configurations + $alias = $container->getAlias($class); + self::assertSame($id, (string) $alias); + self::assertTrue($alias->isPublic()); + } + + public function testEveryTaskIsTested(): void + { + $container = new ContainerBuilder(); + (new CleverAgeDoctrineProcessExtension())->load([], $container); + + $ids = array_filter( + array_keys($container->getDefinitions()), + static fn (string $id): bool => str_starts_with($id, 'cleverage_doctrine_process.task.') + ); + self::assertEqualsCanonicalizing(array_column(iterator_to_array(self::provideTasks()), 0), array_values($ids)); + } +} diff --git a/tests/Task/Database/DatabaseReaderTaskSqliteTest.php b/tests/Task/Database/DatabaseReaderTaskSqliteTest.php index 72e49e1..d721943 100644 --- a/tests/Task/Database/DatabaseReaderTaskSqliteTest.php +++ b/tests/Task/Database/DatabaseReaderTaskSqliteTest.php @@ -107,15 +107,33 @@ public function testArrayParameter(): void self::assertSame([['title' => 'It'], ['title' => 'Fahrenheit 451']], $this->iterate($task, $state, null)); } + public function testNextBeforeExecute(): void + { + [$task, $state] = $this->createTask(['table' => 'book']); + + self::assertFalse($task->next($state)); + } + + public function testConnectionOption(): void + { + $doctrine = $this->createMock(ManagerRegistry::class); + $doctrine->expects(self::once())->method('getConnection')->with('legacy')->willReturn($this->connection); + [$task, $state] = $this->createTask(['sql' => 'SELECT id FROM book WHERE id = 1', 'connection' => 'legacy'], $doctrine); + + self::assertSame([['id' => 1]], $this->iterate($task, $state, null)); + } + /** * @param array $options * * @return array{DatabaseReaderTask, ProcessState} */ - private function createTask(array $options): array + private function createTask(array $options, ?ManagerRegistry $doctrine = null): array { - $doctrine = $this->createStub(ManagerRegistry::class); - $doctrine->method('getConnection')->willReturn($this->connection); + if (!$doctrine instanceof ManagerRegistry) { + $doctrine = $this->createStub(ManagerRegistry::class); + $doctrine->method('getConnection')->willReturn($this->connection); + } $processConfiguration = new ProcessConfiguration('test', []); $state = new ProcessState($processConfiguration, new ProcessHistory($processConfiguration)); diff --git a/tests/Task/Database/DatabaseUpdaterTaskSqliteTest.php b/tests/Task/Database/DatabaseUpdaterTaskSqliteTest.php new file mode 100644 index 0000000..22ba242 --- /dev/null +++ b/tests/Task/Database/DatabaseUpdaterTaskSqliteTest.php @@ -0,0 +1,154 @@ +connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $this->connection->executeStatement('CREATE TABLE book (id INTEGER PRIMARY KEY, title VARCHAR(255), stock INTEGER)'); + foreach (['It', 'Salem', 'Fahrenheit 451'] as $i => $title) { + $this->connection->insert('book', ['id' => $i + 1, 'title' => $title, 'stock' => 0]); + } + } + + public function testInputAsParamsByDefault(): void + { + [$task, $state] = $this->createTask(['sql' => 'UPDATE book SET stock = :stock WHERE id = :id']); + + self::assertSame(1, $this->execute($task, $state, ['stock' => 5, 'id' => 2])); + self::assertSame(1, $this->execute($task, $state, ['stock' => 7, 'id' => 3])); + + self::assertSame([0, 5, 7], $this->getStocks()); + } + + public function testParamsOption(): void + { + [$task, $state] = $this->createTask([ + 'sql' => 'UPDATE book SET stock = :stock WHERE id IN (:ids)', + 'input_as_params' => false, + 'params' => ['stock' => 3, 'ids' => [1, 3]], + 'types' => ['ids' => ArrayParameterType::INTEGER], + ]); + + // The input is ignored, the number of affected rows is output + self::assertSame(2, $this->execute($task, $state, ['stock' => 9])); + + self::assertSame([3, 0, 3], $this->getStocks()); + } + + public function testNoAffectedRow(): void + { + [$task, $state] = $this->createTask(['sql' => 'UPDATE book SET stock = 1 WHERE id = :id']); + + self::assertSame(0, $this->execute($task, $state, ['id' => 42])); + } + + public function testNonArrayInputIsRejected(): void + { + [$task, $state] = $this->createTask(['sql' => 'UPDATE book SET stock = 1']); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Expecting an array of params'); + $this->execute($task, $state, 'not an array'); + } + + public function testSqlIsRequired(): void + { + $this->expectException(MissingOptionsException::class); + $this->createTask([]); + } + + public function testInvalidOptionType(): void + { + $this->expectException(InvalidOptionsException::class); + $this->createTask(['sql' => 'UPDATE book SET stock = 1', 'input_as_params' => 'yes']); + } + + public function testConnectionOption(): void + { + $doctrine = $this->createMock(ManagerRegistry::class); + $doctrine->expects(self::once())->method('getConnection')->with('legacy')->willReturn($this->connection); + [$task, $state] = $this->createTask(['sql' => 'UPDATE book SET stock = 1', 'connection' => 'legacy'], $doctrine); + + self::assertSame(3, $this->execute($task, $state, [])); + } + + /** + * @param array $options + * + * @return array{DatabaseUpdaterTask, ProcessState} + */ + private function createTask(array $options, ?ManagerRegistry $doctrine = null): array + { + if (!$doctrine instanceof ManagerRegistry) { + $doctrine = $this->createStub(ManagerRegistry::class); + $doctrine->method('getConnection')->willReturn($this->connection); + } + + $processConfiguration = new ProcessConfiguration('test', []); + $state = new ProcessState($processConfiguration, new ProcessHistory($processConfiguration)); + $state->setContextualOptionResolver(new ContextualOptionResolver()); + $state->setContext([]); + $state->setTaskConfiguration(new TaskConfiguration('update', DatabaseUpdaterTask::class, $options)); + + $task = new DatabaseUpdaterTask($doctrine, new NullLogger()); + $task->initialize($state); + + return [$task, $state]; + } + + private function execute(DatabaseUpdaterTask $task, ProcessState $state, mixed $input): mixed + { + $state->reset(true); + $state->setInput($input); + $task->execute($state); + + return $state->getOutput(); + } + + /** + * @return list + */ + private function getStocks(): array + { + return array_map( + static fn (mixed $stock): int => is_numeric($stock) ? (int) $stock : -1, + $this->connection->fetchFirstColumn('SELECT stock FROM book ORDER BY id') + ); + } +} diff --git a/tests/Task/EntityManager/AbstractDoctrineQueryTaskTest.php b/tests/Task/EntityManager/AbstractDoctrineQueryTaskTest.php index d3d3e3a..ef7c26a 100644 --- a/tests/Task/EntityManager/AbstractDoctrineQueryTaskTest.php +++ b/tests/Task/EntityManager/AbstractDoctrineQueryTaskTest.php @@ -36,4 +36,55 @@ public function testGetQueryBuilderWithInvalidField(): void $method->invoke($task, $repository, ['e.field; DROP TABLE dummy;' => 'value'], []); } + + public function testGetQueryBuilder(): void + { + $qb = $this->getQueryBuilder( + ['title' => 'It', 'deletedAt' => null, 'id' => [1, 3]], + ['title' => 'ASC', 'id' => null], + 10, + 20 + ); + + self::assertMatchesRegularExpression( + '/^SELECT e FROM App\\\\Entity\\\\Book e WHERE e\\.title = :(param_[0-9a-f]{8}) AND e\\.deletedAt IS null AND e\\.id IN \\(:(param_[0-9a-f]{8})\\) ORDER BY e\\.title ASC, e\\.id ASC$/', + $qb->getDQL() + ); + $parameters = []; + foreach ($qb->getParameters() as $parameter) { + $parameters[] = $parameter->getValue(); + } + // No parameter for the null criteria, ASC by default for a null order + self::assertSame(['It', [1, 3]], $parameters); + self::assertSame(10, $qb->getMaxResults()); + self::assertSame(20, $qb->getFirstResult()); + } + + public function testGetQueryBuilderWithoutCriteria(): void + { + $qb = $this->getQueryBuilder([], []); + + self::assertSame('SELECT e FROM App\\Entity\\Book e', $qb->getDQL()); + self::assertNull($qb->getMaxResults()); + self::assertSame(0, $qb->getFirstResult()); + } + + /** + * @param array|null> $criteria + * @param array $orderBy + */ + private function getQueryBuilder(array $criteria, array $orderBy, ?int $limit = null, ?int $offset = null): QueryBuilder + { + $task = $this->createStub(AbstractDoctrineQueryTask::class); + $repository = $this->createStub(EntityRepository::class); + $repository->method('createQueryBuilder')->willReturn( + (new QueryBuilder($this->createStub(EntityManagerInterface::class)))->select('e')->from('App\\Entity\\Book', 'e') + ); + + /** @var QueryBuilder $qb */ + $qb = (new \ReflectionMethod(AbstractDoctrineQueryTask::class, 'getQueryBuilder')) + ->invoke($task, $repository, $criteria, $orderBy, $limit, $offset); + + return $qb; + } } diff --git a/tests/Task/EntityManager/DoctrineReaderTaskTest.php b/tests/Task/EntityManager/DoctrineReaderTaskTest.php index ab8ea83..3b2a5e1 100644 --- a/tests/Task/EntityManager/DoctrineReaderTaskTest.php +++ b/tests/Task/EntityManager/DoctrineReaderTaskTest.php @@ -267,6 +267,15 @@ protected function getOptions(?ProcessState $state = null): array $task->execute($state); } + public function testNextBeforeExecute(): void + { + [$task, $state] = $this->createIteratingTask(static function (): \Generator { + yield (object) ['name' => 'entity1']; + }); + + self::assertFalse($task->next($state)); + } + public function testEntitiesAreHydratedWhileIterating(): void { $consumed = 0;