diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c33e8914..9bdb6a03 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,6 +10,8 @@ jobs: tests: name: Tests (PHP ${{ matrix.php }}, ext-mongodb ${{ matrix.mongo-ext }}, MongoDB ${{ matrix.mongo-img }}, Symfony ${{ matrix.symfony }}) runs-on: ubuntu-latest + env: + SYMFONY_REQUIRE: ${{ matrix.symfony }} services: mongo: image: mongo:${{ matrix.mongo-img }} @@ -38,9 +40,10 @@ jobs: - php: 8.2 mongo-ext: 1.15.0 mongo-img: 6.0 + symfony: "^6.4" - php: 8.3 - mongo-ext: 1.16.0 - mongo-img: 6.0 + mongo-ext: 1.19.0 + mongo-img: 7.0 steps: - name: Checkout @@ -50,12 +53,10 @@ jobs: with: php-version: ${{ matrix.php }} extensions: mongodb-${{ matrix.mongo-ext }} + tools: flex - name: Allow unstable dependencies run: composer config minimum-stability dev if: matrix.symfony == 'dev-master' - - name: Restrict Symfony version - run: composer require "symfony/symfony:${{ matrix.symfony }}" --no-update - if: matrix.symfony - name: Install dependencies uses: ramsey/composer-install@v3 - name: Await a bit for Mongo to spin up... diff --git a/composer.json b/composer.json index 28285608..aeb9b1cf 100644 --- a/composer.json +++ b/composer.json @@ -25,18 +25,22 @@ "symfony/framework-bundle": "^4.4 || ^5.0 || ^6.0 || ^7.0" }, "require-dev": { - "matthiasnoback/symfony-dependency-injection-test": "^4", + "matthiasnoback/symfony-dependency-injection-test": "^4 || ^5", "symfony/web-profiler-bundle": "^4.4 || ^5.0 || ^6.0 || ^7.0", "symfony/console": "^4.4 || ^5.0 || ^6.0 || ^7.0", "phpunit/phpunit": "^9.6.13 || ^10.5.27", + "symfony/browser-kit": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/dom-crawler": "^4.4 || ^5.0 || ^6.0 || ^7.0", "symfony/phpunit-bridge": "^7.0", + "symfony/routing": "^4.4 || ^5.0 || ^6.0 || ^7.0", "facile-it/facile-coding-standard": "1.2.0", "phpstan/phpstan": "1.11.7", "phpstan/extension-installer": "1.4.1", "jangregor/phpstan-prophecy": "1.0.2", "phpspec/prophecy": "^1.17", "rector/rector": "^1.0.3", - "phpspec/prophecy-phpunit": "^2.0" + "phpspec/prophecy-phpunit": "^2.0", + "symfony/monolog-bundle": "*" }, "minimum-stability": "stable", "suggest": { diff --git a/src/Controller/ProfilerController.php b/src/Controller/ProfilerController.php index b5a7b025..57125582 100644 --- a/src/Controller/ProfilerController.php +++ b/src/Controller/ProfilerController.php @@ -6,49 +6,45 @@ use Facile\MongoDbBundle\DataCollector\MongoDbDataCollector; use Facile\MongoDbBundle\DataCollector\MongoQuerySerializer; +use Facile\MongoDbBundle\Services\Explain\ExplainQueryService; use MongoDB\BSON\UTCDateTime; -use Symfony\Component\DependencyInjection\Container; -use Symfony\Component\DependencyInjection\ContainerAwareInterface; -use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Profiler\Profiler; -class ProfilerController implements ContainerAwareInterface +class ProfilerController { - private ?ContainerInterface $container = null; + private ExplainQueryService $explain; - /** - * Sets the container. - * - * @param ContainerInterface|null $container A ContainerInterface instance or null - */ - public function setContainer(ContainerInterface $container = null): void + private ?Profiler $profiler; + + public function __construct(ExplainQueryService $explain, ?Profiler $profiler) { - $this->container = $container; + $this->explain = $explain; + $this->profiler = $profiler; } - /** - * @throws \Exception - */ public function explainAction(string $token, $queryNumber): JsonResponse { - /** @var Profiler $profiler */ - $profiler = $this->container->get('profiler'); - $profiler->disable(); + $this->profiler->disable(); + + $profile = $this->profiler->loadProfile($token); + if (! $profile) { + throw new \RuntimeException('No profile found'); + } - $profile = $profiler->loadProfile($token); - /** @var MongoDbDataCollector $dataCollector */ $dataCollector = $profile->getCollector('mongodb'); + if (! $dataCollector instanceof MongoDbDataCollector) { + throw new \RuntimeException('MongoDb data collector not found'); + } + $queries = $dataCollector->getQueries(); $query = $queries[$queryNumber]; $query->setFilters($this->walkAndConvertToUTCDatetime($query->getFilters())); - $service = $this->container->get('mongo.explain_query_service'); - try { - $result = $service->execute($query); + $result = $this->explain->execute($query); } catch (\InvalidArgumentException $e) { return new JsonResponse([ 'err' => $e->getMessage(), diff --git a/src/Resources/config/profiler.xml b/src/Resources/config/profiler.xml index a77cff53..404773b2 100644 --- a/src/Resources/config/profiler.xml +++ b/src/Resources/config/profiler.xml @@ -42,5 +42,12 @@ + + + + + + + diff --git a/tests/Functional/Controller/ProfilerControllerTest.php b/tests/Functional/Controller/ProfilerControllerTest.php index 22be035a..f3d486ec 100644 --- a/tests/Functional/Controller/ProfilerControllerTest.php +++ b/tests/Functional/Controller/ProfilerControllerTest.php @@ -4,99 +4,88 @@ namespace Facile\MongoDbBundle\Tests\Functional\Controller; +use Facile\MongoDbBundle\Tests\Functional\TestApp\TestKernelWithProfiler; use Prophecy\PhpUnit\ProphecyTrait; -use Facile\MongoDbBundle\Controller\ProfilerController; -use Facile\MongoDbBundle\DataCollector\MongoDbDataCollector; -use Facile\MongoDbBundle\Models\Query; -use Facile\MongoDbBundle\Tests\Functional\AppTestCase; -use MongoDB\BSON\UTCDateTime; -use Symfony\Component\DependencyInjection\Container; -use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpKernel\Profiler\Profile; -use Symfony\Component\HttpKernel\Profiler\Profiler; - -class ProfilerControllerTest extends AppTestCase +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\DomCrawler\Crawler; + +class ProfilerControllerTest extends WebTestCase { use ProphecyTrait; + protected static function getKernelClass(): string + { + return TestKernelWithProfiler::class; + } + protected function setUp(): void { - $this->setEnvDev(); + $this->cleanUpDir(__DIR__ . '/../../../var/cache'); parent::setUp(); } public function test_explainAction(): void { - $query = new Query(); - $query->setClient('test_client'); - $query->setDatabase('testFunctionaldb'); - $query->setCollection('fooCollection'); - $query->setMethod('count'); - $query->setFilters(['date' => new UTCDateTime((new \DateTime())->getTimestamp() * 1_000)]); - - $collector = $this->prophesize(MongoDbDataCollector::class); - $collector->getQueries()->shouldBeCalledTimes(1)->willReturn([$query]); - - $profile = $this->prophesize(Profile::class); - $profile->getCollector('mongodb')->shouldBeCalledTimes(1)->willReturn($collector->reveal()); - - $profiler = $this->prophesize(Profiler::class); - $profiler->loadProfile('fooToken')->shouldBeCalledTimes(1)->willReturn($profile->reveal()); - $profiler->disable()->shouldBeCalledTimes(1); - - $explainService = $this->getContainer()->get('mongo.explain_query_service'); - - $container = $this->prophesize(Container::class); - $container->get('profiler')->willReturn($profiler->reveal()); - $container->get('mongo.explain_query_service')->willReturn($explainService); - - $controller = new ProfilerController(); - $controller->setContainer($container->reveal()); - - $response = $controller->explainAction('fooToken', 0); - - $this->assertInstanceOf(JsonResponse::class, $response); - $this->assertEquals(200, $response->getStatusCode()); - - $data = json_decode($response->getContent(), true); - $this->assertEquals(JSON_ERROR_NONE, json_last_error()); - - $this->assertTrue(is_array($data)); - $this->assertArrayNotHasKey('err', $data); + $client = self::createClient(); + + $client->request('GET', '/trigger_query'); + $this->assertResponseIsSuccessful(); + $crawler = $client->request('GET', '/_profiler/latest?panel=mongodb'); + + $this->assertResponseIsSuccessful(); + $this->assertHeadersArePresent($crawler, 'http://localhost/trigger_query'); + $explainTable = $crawler->filterXPath('//table[2]'); + $this->assertCrawlerTextContainsString('insertOne', $explainTable); + $this->assertCrawlerTextContainsString('test_collection', $explainTable); + $this->assertCrawlerTextContainsString('{ "foo": "bar" }', $explainTable); } public function test_explainAction_error(): void { - $query = new Query(); - $query->setMethod('fooo'); - - $collector = $this->prophesize(MongoDbDataCollector::class); - $collector->getQueries()->shouldBeCalledTimes(1)->willReturn([$query]); - - $profile = $this->prophesize(Profile::class); - $profile->getCollector('mongodb')->shouldBeCalledTimes(1)->willReturn($collector->reveal()); - - $profiler = $this->prophesize(Profiler::class); - $profiler->loadProfile('fooToken')->shouldBeCalledTimes(1)->willReturn($profile->reveal()); - $profiler->disable()->shouldBeCalledTimes(1); - - $explainService = $this->getContainer()->get('mongo.explain_query_service'); - - $container = $this->prophesize(Container::class); - $container->get('profiler')->willReturn($profiler->reveal()); - $container->get('mongo.explain_query_service')->willReturn($explainService); + $client = self::createClient(); - $controller = new ProfilerController(); - $controller->setContainer($container->reveal()); + $client->request('GET', '/noop'); + $this->assertResponseIsSuccessful(); + $crawler = $client->request('GET', '/_profiler/latest?panel=mongodb'); + $this->assertResponseIsSuccessful(); - $response = $controller->explainAction('fooToken', 0); + $this->assertHeadersArePresent($crawler, 'http://localhost/noop'); + $explainTable = $crawler->filterXPath('//table[2]'); + $this->assertCrawlerTextContainsString('No queries', $explainTable); + } - $this->assertInstanceOf(JsonResponse::class, $response); - $this->assertEquals(200, $response->getStatusCode()); + private function cleanUpDir(string $dir): bool + { + if (! file_exists($dir)) { + return false; + } + + $it = new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS); + $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); + + foreach ($files as $file) { + if ($file->isDir()) { + $this->cleanUpDir($file->getRealPath()); + } else { + unlink($file->getRealPath()); + } + } + + return rmdir($dir); + } - $data = json_decode($response->getContent(), true); + private function assertHeadersArePresent(Crawler $crawler, string $expectedTitle): void + { + $this->assertCrawlerTextContainsString($expectedTitle, $crawler->filterXPath('//h2[1]')); + $this->assertCrawlerTextContainsString('Mongo DB Query Metrics', $crawler->filterXPath('//h2[2]')); + $this->assertCrawlerTextContainsString('Connections list', $crawler->filterXPath('//h2[3]')); + $this->assertCrawlerTextContainsString('Queries Detail', $crawler->filterXPath('//h2[4]')); + $this->assertCrawlerTextContainsString('test_client.testFunctionaldb', $crawler->filterXPath('//table[1]')); + } - $this->assertTrue(is_array($data)); - $this->assertArrayHasKey('err', $data); + private function assertCrawlerTextContainsString(string $needle, Crawler $explainTable): void + { + // silence 4.4 deprecation about whitespace normalization + $this->assertStringContainsString($needle, $explainTable->text('', true)); } } diff --git a/tests/Functional/TestApp/MainController.php b/tests/Functional/TestApp/MainController.php new file mode 100644 index 00000000..c1d80245 --- /dev/null +++ b/tests/Functional/TestApp/MainController.php @@ -0,0 +1,31 @@ +database = $database; + } + + public function noop(): Response + { + return new Response('Hello there'); + } + + public function triggerQuery(): Response + { + $this->database->selectCollection('test_collection') + ->insertOne(['foo' => 'bar']); + + return new Response('Hello there'); + } +} diff --git a/tests/Functional/TestApp/TestKernel.php b/tests/Functional/TestApp/TestKernel.php index 1e46cf59..50127735 100644 --- a/tests/Functional/TestApp/TestKernel.php +++ b/tests/Functional/TestApp/TestKernel.php @@ -6,6 +6,7 @@ use Facile\MongoDbBundle\FacileMongoDbBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\MonologBundle\MonologBundle; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\HttpKernel\Kernel; @@ -21,6 +22,7 @@ public function registerBundles(): array { return [ new FrameworkBundle(), + new MonologBundle(), new FacileMongoDbBundle(), ]; } @@ -30,22 +32,18 @@ public function registerBundles(): array */ public function registerContainerConfiguration(LoaderInterface $loader): void { - $suffix = ''; - $version = ''; + $loader->load(__DIR__ . '/config.yaml'); if ('docker' === getenv('TEST_ENV')) { - $suffix = '_docker'; + $loader->load(__DIR__ . '/docker.yaml'); } if (version_compare(Kernel::VERSION, '6.1.0') >= 0) { - $version = '_61'; + $loader->load(__DIR__ . '/deprecations_6.1.yml'); } if (version_compare(Kernel::VERSION, '6.4.0') >= 0) { - $version = '_64'; + $loader->load(__DIR__ . '/deprecations_6.4.yml'); } - - $configFile = sprintf('/config_test%s%s.yml', $version, $suffix); - $loader->load(__DIR__ . $configFile); } } diff --git a/tests/Functional/TestApp/TestKernelWithProfiler.php b/tests/Functional/TestApp/TestKernelWithProfiler.php new file mode 100644 index 00000000..2597ae27 --- /dev/null +++ b/tests/Functional/TestApp/TestKernelWithProfiler.php @@ -0,0 +1,52 @@ +load(__DIR__ . '/services.yaml'); + $loader->load(__DIR__ . '/config_profiler.yml'); + + if (version_compare(Kernel::VERSION, '5.1.0') >= 0) { + $loader->load(__DIR__ . '/deprecations_5.1.yml'); + } + } + + protected function build(ContainerBuilder $container): void + { + $container->setParameter('routing_config_dir', __DIR__); + + parent::build($container); + } + + protected function configureRoutes(RouteCollectionBuilder $routes) + { + // noop - 4.4 backward compat + } + + protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader) + { + // noop - 4.4 backward compat + } +} diff --git a/tests/Functional/TestApp/config_test.yml b/tests/Functional/TestApp/config.yaml similarity index 78% rename from tests/Functional/TestApp/config_test.yml rename to tests/Functional/TestApp/config.yaml index b50be7ce..3d36584c 100644 --- a/tests/Functional/TestApp/config_test.yml +++ b/tests/Functional/TestApp/config.yaml @@ -1,3 +1,6 @@ +parameters: + mongo_host: localhost + framework: secret: "Four can keep a secret, if three of them are dead." @@ -8,7 +11,7 @@ mongo_db_bundle: password: rootPass authSource: admin hosts: - - { host: localhost, port: 27017 } + - { host: '%mongo_host%', port: 27017 } connections: test_db: diff --git a/tests/Functional/TestApp/config_profiler.yml b/tests/Functional/TestApp/config_profiler.yml new file mode 100644 index 00000000..d3a5e6ee --- /dev/null +++ b/tests/Functional/TestApp/config_profiler.yml @@ -0,0 +1,8 @@ +framework: + router: { resource: "%routing_config_dir%/routing.yaml" } + test: true + profiler: true + +twig: + exception_controller: ~ + strict_variables: "%kernel.debug%" diff --git a/tests/Functional/TestApp/config_test_61.yml b/tests/Functional/TestApp/config_test_61.yml deleted file mode 100644 index bcbfa1d3..00000000 --- a/tests/Functional/TestApp/config_test_61.yml +++ /dev/null @@ -1,17 +0,0 @@ -framework: - secret: "Four can keep a secret, if three of them are dead." - http_method_override: true - -mongo_db_bundle: - clients: - test_client: - username: root - password: rootPass - authSource: admin - hosts: - - { host: localhost, port: 27017 } - - connections: - test_db: - client_name: test_client - database_name: testFunctionaldb diff --git a/tests/Functional/TestApp/config_test_61_docker.yml b/tests/Functional/TestApp/config_test_61_docker.yml deleted file mode 100644 index b1dddaaa..00000000 --- a/tests/Functional/TestApp/config_test_61_docker.yml +++ /dev/null @@ -1,17 +0,0 @@ -framework: - secret: "Four can keep a secret, if three of them are dead." - http_method_override: true - -mongo_db_bundle: - clients: - test_client: - username: root - password: rootPass - authSource: admin - hosts: - - { host: mongo, port: 27017 } - - connections: - test_db: - client_name: test_client - database_name: testFunctionaldb diff --git a/tests/Functional/TestApp/config_test_64_docker.yml b/tests/Functional/TestApp/config_test_64_docker.yml deleted file mode 100644 index a20c3234..00000000 --- a/tests/Functional/TestApp/config_test_64_docker.yml +++ /dev/null @@ -1,6 +0,0 @@ -imports: - - { resource: ./config_test_61_docker.yml } - -framework: - handle_all_throwables: true - php_errors: true diff --git a/tests/Functional/TestApp/config_test_docker.yml b/tests/Functional/TestApp/config_test_docker.yml deleted file mode 100644 index 245349a6..00000000 --- a/tests/Functional/TestApp/config_test_docker.yml +++ /dev/null @@ -1,16 +0,0 @@ -framework: - secret: "Four can keep a secret, if three of them are dead." - -mongo_db_bundle: - clients: - test_client: - username: root - password: rootPass - authSource: admin - hosts: - - { host: mongo, port: 27017 } - - connections: - test_db: - client_name: test_client - database_name: testFunctionaldb diff --git a/tests/Functional/TestApp/deprecations_5.1.yml b/tests/Functional/TestApp/deprecations_5.1.yml new file mode 100644 index 00000000..5ed9bbbf --- /dev/null +++ b/tests/Functional/TestApp/deprecations_5.1.yml @@ -0,0 +1,3 @@ +framework: + router: + utf8: true diff --git a/tests/Functional/TestApp/deprecations_6.1.yml b/tests/Functional/TestApp/deprecations_6.1.yml new file mode 100644 index 00000000..072634e7 --- /dev/null +++ b/tests/Functional/TestApp/deprecations_6.1.yml @@ -0,0 +1,2 @@ +framework: + http_method_override: true diff --git a/tests/Functional/TestApp/config_test_64.yml b/tests/Functional/TestApp/deprecations_6.4.yml similarity index 58% rename from tests/Functional/TestApp/config_test_64.yml rename to tests/Functional/TestApp/deprecations_6.4.yml index 4856dc5b..109d2e1a 100644 --- a/tests/Functional/TestApp/config_test_64.yml +++ b/tests/Functional/TestApp/deprecations_6.4.yml @@ -1,6 +1,3 @@ -imports: - - { resource: ./config_test_61.yml } - framework: handle_all_throwables: true php_errors: diff --git a/tests/Functional/TestApp/docker.yaml b/tests/Functional/TestApp/docker.yaml new file mode 100644 index 00000000..04e005ed --- /dev/null +++ b/tests/Functional/TestApp/docker.yaml @@ -0,0 +1,2 @@ +parameters: + mongo_host: mongo diff --git a/tests/Functional/TestApp/routing.yaml b/tests/Functional/TestApp/routing.yaml new file mode 100644 index 00000000..fe3b3ccc --- /dev/null +++ b/tests/Functional/TestApp/routing.yaml @@ -0,0 +1,17 @@ +noop: + path: /noop + defaults: { _controller: '\Facile\MongoDbBundle\Tests\Functional\TestApp\MainController::noop' } + +trigger_query: + path: /trigger_query + defaults: { _controller: '\Facile\MongoDbBundle\Tests\Functional\TestApp\MainController::triggerQuery' } + +## profiler + +web_profiler_wdt: + resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' + prefix: /_wdt + +web_profiler_profiler: + resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' + prefix: /_profiler diff --git a/tests/Functional/TestApp/services.yaml b/tests/Functional/TestApp/services.yaml new file mode 100644 index 00000000..eeaf35b9 --- /dev/null +++ b/tests/Functional/TestApp/services.yaml @@ -0,0 +1,6 @@ +services: + Facile\MongoDbBundle\Tests\Functional\TestApp\MainController: + arguments: + $database: '@mongo.connection.test_db' + tags: + - controller.service_arguments