diff --git a/CLAUDE.md b/CLAUDE.md index 3ecdf7f..8ee652d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,9 @@ This is a library/bundle — there is no runnable application here. It is exerci PHP 8.1+ is required (CI runs 8.1 and 8.2). Use the `8.1`/`8.2` shell switchers if needed. The dev tooling is the individually-pinned set from `setono/code-quality-pack` (inlined into `require-dev` rather than depending on the meta-package); PHPUnit is held at `^10.5` so PHP 8.1 stays supported (PHPUnit 11 needs 8.2). -- Tests: `composer phpunit` (or `vendor/bin/phpunit`). Unit tests live in `tests/Unit` (the `unit` suite: `vendor/bin/phpunit --testsuite unit`) +- Tests: `composer phpunit` (or `vendor/bin/phpunit`) runs both suites; pick one with `--testsuite unit` or `--testsuite functional` + - `tests/Unit`: isolated classes with Prophecy doubles. + - `tests/Functional`: the bundle inside a real Symfony app (`tests/Functional/App/Kernel.php`: FrameworkBundle + DoctrineBundle + DAMA DoctrineTestBundle + this bundle), driven over HTTP with `KernelBrowser` against a SQLite database. `tests/bootstrap.php` creates the schema once per PHPUnit process, and DAMA's PHPUnit extension (registered in `phpunit.xml.dist`) runs every test in a transaction that it rolls back. So tests must not run DDL: MySQL commits a transaction on DDL. To check which queries a request ran, read DoctrineBundle's debug data holder (see `MetadataTest::getExecutedQueries()`). Extend `FunctionalTestCase`, which rethrows exceptions instead of rendering error pages and has helpers to read the stored metadata. Every request gets a new container, like PHP-FPM. The kernel always runs in the `test` environment with debug on, whatever `APP_ENV`/`APP_DEBUG` say (the Symfony CLI's `php` sets `APP_ENV=dev`). Its cache, log and database live in `sys_get_temp_dir()/setono-client-bundle-/`, so nothing is generated in the repo, and neither two checkouts nor parallel Infection processes share a container or a database. The bootstrap also removes the cached container, because Infection swaps in mutated code without changing file times. Set `SETONO_CLIENT_BUNDLE_DATABASE_URL` (not `DATABASE_URL`, on purpose) to run against another database: the bootstrap drops the bundle's tables in it, and all Infection processes share it, so run Infection with `--threads=1` then. - Single test: `vendor/bin/phpunit --filter after_loading_the_correct_parameter_has_been_set` or by path `vendor/bin/phpunit tests/Unit/DependencyInjection/SetonoClientExtensionTest.php` - Static analysis: `composer analyse` (PHPStan at `level: max`, config in `phpstan.neon.dist`; auto-loads the doctrine/symfony/phpunit/strict-rules extensions via `phpstan/extension-installer`) - Check style: `composer check-style` (ECS, Sylius Labs ruleset) diff --git a/README.md b/README.md index 92cd20c..fb188f2 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ Typical use cases: first‑party analytics, marketing attribution, returning‑v | --- | --- | | PHP | `>= 8.1` | | Symfony | `^6.4` or `^7.0` | -| Doctrine ORM | `^2.0` or `^3.0` | +| Doctrine ORM | `^2.17` or `^3.0` | +| Doctrine DBAL | `^3.7` or `^4.0` | +| DoctrineBundle | `^2.11.3` | ## Installation diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 1974ae0..7037bea 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -4,5 +4,6 @@ use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType; return (new Configuration()) - ->ignoreErrorsOnPackage('symfony/uid', [ErrorType::UNUSED_DEPENDENCY]) // When initializing the default client context, the Uuid class from this component is used + // setono/client generates the client ids with symfony/uid when the default client context creates a client, so src/ doesn't reference it, only the tests + ->ignoreErrorsOnPackage('symfony/uid', [ErrorType::PROD_DEPENDENCY_ONLY_IN_DEV]) ; diff --git a/composer.json b/composer.json index 6f881b6..3e837a9 100644 --- a/composer.json +++ b/composer.json @@ -11,9 +11,9 @@ ], "require": { "php": ">=8.1", - "doctrine/dbal": "^2.13 || ^3.0 || ^4.0", - "doctrine/doctrine-bundle": "^2.7", - "doctrine/orm": "^2.0 || ^3.0", + "doctrine/dbal": "^3.7 || ^4.0", + "doctrine/doctrine-bundle": "^2.11.3", + "doctrine/orm": "^2.17 || ^3.0", "doctrine/persistence": "^2.5 || ^3.0", "psr/event-dispatcher": "^1.0", "setono/client": "^1.1.1", @@ -27,6 +27,7 @@ "symfony/var-exporter": "^6.4 || ^7.0" }, "require-dev": { + "dama/doctrine-test-bundle": "^8.2", "ergebnis/composer-normalize": "^2.50", "infection/infection": "^0.27 || ^0.28", "jangregor/phpstan-prophecy": "^2.3", @@ -44,7 +45,13 @@ "phpunit/phpunit": "^10.5", "rector/rector": "^2.4.1", "shipmonk/composer-dependency-analyser": "^1.8.4", - "sylius-labs/coding-standard": "^4.5" + "sylius-labs/coding-standard": "^4.5", + "symfony/browser-kit": "^6.4 || ^7.0", + "symfony/doctrine-bridge": "^6.4 || ^7.0", + "symfony/filesystem": "^6.4 || ^7.0", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/routing": "^6.4 || ^7.0", + "webmozart/assert": "^1.11 || ^2.0" }, "prefer-stable": true, "autoload": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 14e4ada..2019b86 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,12 +1,18 @@ tests/Unit + + tests/Functional + + + + diff --git a/tests/Functional/App/Controller/ClientController.php b/tests/Functional/App/Controller/ClientController.php new file mode 100644 index 0000000..f817e25 --- /dev/null +++ b/tests/Functional/App/Controller/ClientController.php @@ -0,0 +1,100 @@ + $metadataClass + */ + public function __construct( + private readonly ClientContextInterface $clientContext, + private readonly ManagerRegistry $managerRegistry, + #[Autowire('%setono_client.metadata_class%')] + private readonly string $metadataClass, + ) { + } + + /** + * Uses the client, but not its metadata + */ + public function id(): Response + { + return new Response($this->clientContext->getClient()->id); + } + + public function showMetadata(Client $client): JsonResponse + { + return new JsonResponse(iterator_to_array($client->metadata)); + } + + public function setMetadata(Request $request, Client $client): Response + { + $ttl = $request->query->get('ttl'); + + $client->metadata->set( + $request->query->getString('key'), + $request->query->getString('value'), + null === $ttl ? null : (int) $ttl, + ); + + return new Response(); + } + + public function removeMetadata(Request $request, Client $client): Response + { + $client->metadata->remove($request->query->getString('key')); + + return new Response(); + } + + /** + * Sets the metadata like setMetadata() does, but simulates a concurrent request from the same client + * that inserts the client's row right before this request does + */ + public function setMetadataConcurrently(Request $request, Client $client): Response + { + $client->metadata->set($request->query->getString('key'), $request->query->getString('value')); + + $manager = $this->managerRegistry->getManagerForClass($this->metadataClass); + Assert::isInstanceOf($manager, EntityManagerInterface::class); + + $manager->getEventManager()->addEventListener(Events::onFlush, new class($manager->getConnection(), $manager->getClassMetadata($this->metadataClass), $client->id) { + /** + * @param ClassMetadata $classMetadata + */ + public function __construct( + private readonly Connection $connection, + private readonly ClassMetadata $classMetadata, + private readonly string $clientId, + ) { + } + + public function onFlush(): void + { + $this->connection->insert($this->classMetadata->getTableName(), [ + $this->classMetadata->getColumnName('clientId') => $this->clientId, + $this->classMetadata->getColumnName('metadata') => json_encode(['set_by' => 'concurrent request']), + ]); + } + }); + + return new Response(); + } +} diff --git a/tests/Functional/App/EventSubscriber/SkipCookieSubscriber.php b/tests/Functional/App/EventSubscriber/SkipCookieSubscriber.php new file mode 100644 index 0000000..db51257 --- /dev/null +++ b/tests/Functional/App/EventSubscriber/SkipCookieSubscriber.php @@ -0,0 +1,29 @@ + 'skipCookie', + ]; + } + + public function skipCookie(PreStoreCookieEvent $event): void + { + if ($event->request->query->has('skip_cookie')) { + $event->store = false; + } + } +} diff --git a/tests/Functional/App/Kernel.php b/tests/Functional/App/Kernel.php new file mode 100644 index 0000000..1be4160 --- /dev/null +++ b/tests/Functional/App/Kernel.php @@ -0,0 +1,114 @@ +getEnvironment(); + } + + public function getLogDir(): string + { + return self::getVarDir() . '/log'; + } + + /** + * The application is configured in this class, so the config dir only receives the config reference + * that Symfony 7.4+ dumps in debug mode + */ + protected function getConfigDir(): string + { + return self::getVarDir() . '/config'; + } + + /** + * Each checkout gets its own directory, and so does each of the parallel processes that Infection runs and gives a token. + * That way, no two test runs share a container or a database + */ + public static function getVarDir(): string + { + return sprintf('%s/setono-client-bundle-%s%s', sys_get_temp_dir(), hash('xxh3', __DIR__), (string) getenv('TEST_TOKEN')); + } + + protected function configureContainer(ContainerConfigurator $container): void + { + $container->extension('framework', [ + 'secret' => 'secret', + 'test' => true, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + ]); + + // Set the SETONO_CLIENT_BUNDLE_DATABASE_URL environment variable to run the tests against another database. The tests drop + // the bundle's tables in it. The variable isn't DATABASE_URL, so the tests can't pick up the database of another application + $container->parameters()->set('env(SETONO_CLIENT_BUNDLE_DATABASE_URL)', 'sqlite:///%kernel.cache_dir%/test.db'); + + $dbal = ['url' => '%env(resolve:SETONO_CLIENT_BUNDLE_DATABASE_URL)%']; + + // DAMA DoctrineTestBundle runs each test in a transaction, so the transactions of the bundle need savepoints. + // DBAL 4 always uses them and deprecates the option. Like DoctrineBundle, this tells DBAL 3 by its getEventManager() + if (method_exists(Connection::class, 'getEventManager')) { + $dbal['use_savepoints'] = true; + } + + $container->extension('doctrine', [ + 'dbal' => $dbal, + 'orm' => [], + ]); + + $services = $container->services(); + + // Logs warnings and errors to a file instead of the test output. It's in the cache dir, so it's removed with the cache + $services->set('logger', Logger::class)->args(['warning', '%kernel.cache_dir%/log.txt']); + + // Lets the tests read the queries that a request executed + $services->alias('test.doctrine.debug_data_holder', 'doctrine.debug_data_holder')->public(); + + $services + ->defaults()->autowire()->autoconfigure() + ->load(__NAMESPACE__ . '\\Controller\\', __DIR__ . '/Controller')->tag('controller.service_arguments') + ->load(__NAMESPACE__ . '\\EventSubscriber\\', __DIR__ . '/EventSubscriber') + ; + } + + protected function configureRoutes(RoutingConfigurator $routes): void + { + $routes->add('client_id', '/')->controller([ClientController::class, 'id']); + $routes->add('metadata_show', '/metadata')->controller([ClientController::class, 'showMetadata']); + $routes->add('metadata_set', '/metadata/set')->controller([ClientController::class, 'setMetadata']); + $routes->add('metadata_remove', '/metadata/remove')->controller([ClientController::class, 'removeMetadata']); + $routes->add('metadata_set_concurrently', '/metadata/set-concurrently')->controller([ClientController::class, 'setMetadataConcurrently']); + } +} diff --git a/tests/Functional/ClientCookieTest.php b/tests/Functional/ClientCookieTest.php new file mode 100644 index 0000000..53967df --- /dev/null +++ b/tests/Functional/ClientCookieTest.php @@ -0,0 +1,122 @@ +browser->request('GET', '/'); + + self::assertResponseIsSuccessful(); + self::assertResponseHasCookie(self::COOKIE_NAME); + + $clientId = $this->getClientId(); + self::assertTrue(Uuid::isValid($clientId)); + self::assertSame($clientId, $this->browser->getResponse()->getContent()); + } + + /** + * @test + */ + public function it_sets_a_cookie_that_javascript_can_read_and_that_expires_in_a_year(): void + { + $before = time(); + $this->browser->request('GET', '/'); + + $cookies = $this->browser->getResponse()->headers->getCookies(); + self::assertCount(1, $cookies); + self::assertFalse($cookies[0]->isHttpOnly()); + self::assertGreaterThanOrEqual(strtotime('+365 days', $before), $cookies[0]->getExpiresTime()); + self::assertLessThanOrEqual(strtotime('+365 days'), $cookies[0]->getExpiresTime()); + } + + /** + * @test + */ + public function it_recognizes_a_returning_visitor(): void + { + $this->browser->request('GET', '/'); + $clientId = $this->getClientId(); + + $this->browser->request('GET', '/'); + + self::assertSame($clientId, $this->browser->getResponse()->getContent()); + self::assertSame($clientId, $this->getClientId()); + } + + /** + * @test + */ + public function it_keeps_the_first_seen_timestamp_and_updates_the_last_seen_timestamp(): void + { + $this->setCookie((new Cookie('client-id', 2, 1_000, 2_000))->toString()); + + $before = time(); + $this->browser->request('GET', '/'); + + $cookie = $this->getCookie(); + self::assertSame('client-id', $cookie->clientId); + self::assertSame(1_000, $cookie->firstSeenAt); + self::assertGreaterThanOrEqual($before, $cookie->lastSeenAt); + } + + /** + * @test + */ + public function it_upgrades_a_version_1_cookie(): void + { + // Version 1 cookies only hold the client id + $this->setCookie('client-id'); + + $this->browser->request('GET', '/'); + + self::assertSame('client-id', $this->browser->getResponse()->getContent()); + + // Checks the raw value, because the version 1 cookie that was sent parses as a version 2 cookie too + self::assertResponseHasCookie(self::COOKIE_NAME); + self::assertMatchesRegularExpression('/^2\.\d+\.\d+\.client-id$/', (string) $this->browser->getCookieJar()->get(self::COOKIE_NAME)?->getValue()); + } + + /** + * @test + */ + public function it_treats_a_visitor_with_an_invalid_cookie_as_a_new_visitor(): void + { + $this->setCookie('1.2.3'); + + $this->browser->request('GET', '/'); + + $clientId = $this->getClientId(); + self::assertTrue(Uuid::isValid($clientId)); + self::assertSame($clientId, $this->browser->getResponse()->getContent()); + } + + /** + * @test + */ + public function it_does_not_set_the_cookie_when_a_listener_vetoes_it(): void + { + $this->browser->request('GET', '/?skip_cookie=1'); + + self::assertResponseIsSuccessful(); + self::assertResponseNotHasCookie(self::COOKIE_NAME); + } + + /** + * Sets the cookie like the browser stores it after receiving it from the application + */ + private function setCookie(string $value): void + { + $this->browser->getCookieJar()->set(new BrowserCookie(self::COOKIE_NAME, $value, null, '/', 'localhost')); + } +} diff --git a/tests/Functional/DoctrineMappingTest.php b/tests/Functional/DoctrineMappingTest.php new file mode 100644 index 0000000..ffade33 --- /dev/null +++ b/tests/Functional/DoctrineMappingTest.php @@ -0,0 +1,37 @@ +getClassMetadata(Metadata::class); + + self::assertFalse($classMetadata->isMappedSuperclass); + self::assertSame('setono_client__metadata', $classMetadata->getTableName()); + self::assertSame(['clientId'], $classMetadata->getIdentifierFieldNames()); + self::assertSame('string', $classMetadata->getTypeOfField('clientId')); + self::assertSame('json', $classMetadata->getTypeOfField('metadata')); + self::assertTrue($classMetadata->isNullable('metadata')); + } + + /** + * @test + */ + public function its_mapping_is_valid_and_matches_the_database_schema(): void + { + $validator = new SchemaValidator(self::getEntityManager()); + + self::assertSame([], $validator->validateMapping()); + self::assertTrue($validator->schemaInSyncWithMetadata()); + } +} diff --git a/tests/Functional/FunctionalTestCase.php b/tests/Functional/FunctionalTestCase.php new file mode 100644 index 0000000..e652d16 --- /dev/null +++ b/tests/Functional/FunctionalTestCase.php @@ -0,0 +1,148 @@ +browser = static::createClient(['environment' => 'test', 'debug' => true]); + + // An exception fails the test instead of becoming an error response + $this->browser->catchExceptions(false); + } + + protected static function getKernelClass(): string + { + return Kernel::class; + } + + /** + * Returns the cookie stored in the browser + */ + protected function getCookie(): Cookie + { + $cookie = $this->browser->getCookieJar()->get(self::COOKIE_NAME); + self::assertNotNull($cookie, 'The browser does not have the client id cookie'); + + return Cookie::fromString($cookie->getValue()); + } + + /** + * Returns the client id stored in the browser's cookie + */ + protected function getClientId(): string + { + return $this->getCookie()->clientId; + } + + /** + * Returns the metadata stored in the database for the given client, or null if the client has no row + * + * @return array|null + */ + protected static function fetchStoredMetadata(string $clientId): ?array + { + $classMetadata = self::getMetadataClassMetadata(); + + $metadata = self::getConnection()->fetchOne(sprintf( + 'SELECT %s FROM %s WHERE %s = ?', + $classMetadata->getColumnName('metadata'), + $classMetadata->getTableName(), + $classMetadata->getColumnName('clientId'), + ), [$clientId]); + + if (false === $metadata) { + return null; + } + + if (null === $metadata) { + return []; + } + + self::assertIsString($metadata); + $metadata = json_decode($metadata, true, 512, \JSON_THROW_ON_ERROR); + self::assertIsArray($metadata); + + return $metadata; + } + + protected static function countStoredMetadata(): int + { + $count = self::getConnection()->fetchOne(sprintf('SELECT COUNT(*) FROM %s', self::getMetadataClassMetadata()->getTableName())); + self::assertIsNumeric($count); + + return (int) $count; + } + + /** + * @param array $metadata + */ + protected static function storeMetadata(string $clientId, array $metadata): void + { + $classMetadata = self::getMetadataClassMetadata(); + + self::getConnection()->update($classMetadata->getTableName(), [ + $classMetadata->getColumnName('metadata') => json_encode($metadata, \JSON_THROW_ON_ERROR), + ], [ + $classMetadata->getColumnName('clientId') => $clientId, + ]); + } + + protected static function getConnection(): Connection + { + return self::getEntityManager()->getConnection(); + } + + protected static function getEntityManager(): EntityManagerInterface + { + $managerRegistry = self::getContainer()->get('doctrine'); + self::assertInstanceOf(ManagerRegistry::class, $managerRegistry); + + // The bundle uses the manager of the metadata class too + $manager = $managerRegistry->getManagerForClass(self::getMetadataClass()); + self::assertInstanceOf(EntityManagerInterface::class, $manager); + + return $manager; + } + + /** + * @return ClassMetadata + */ + protected static function getMetadataClassMetadata(): ClassMetadata + { + return self::getEntityManager()->getClassMetadata(self::getMetadataClass()); + } + + /** + * @return class-string + */ + private static function getMetadataClass(): string + { + $metadataClass = self::getContainer()->getParameter('setono_client.metadata_class'); + self::assertIsString($metadataClass); + self::assertTrue(class_exists($metadataClass)); + + return $metadataClass; + } +} diff --git a/tests/Functional/MetadataTest.php b/tests/Functional/MetadataTest.php new file mode 100644 index 0000000..09fae06 --- /dev/null +++ b/tests/Functional/MetadataTest.php @@ -0,0 +1,198 @@ +browser->request('GET', '/'); + + self::assertResponseIsSuccessful(); + self::assertSame(0, self::countStoredMetadata()); + } + + /** + * @test + */ + public function it_stores_the_metadata_of_a_new_visitor(): void + { + $this->browser->request('GET', '/metadata/set?key=foo&value=bar'); + + self::assertResponseIsSuccessful(); + self::assertSame(['foo' => 'bar'], self::fetchStoredMetadata($this->getClientId())); + } + + /** + * @test + */ + public function it_loads_the_stored_metadata_of_a_returning_visitor(): void + { + $this->browser->request('GET', '/metadata/set?key=foo&value=bar'); + + $this->browser->request('GET', '/metadata'); + + self::assertResponseIsSuccessful(); + self::assertSame(['foo' => 'bar'], $this->getResponseData()); + } + + /** + * @test + */ + public function it_updates_the_stored_metadata_of_a_returning_visitor(): void + { + $this->browser->request('GET', '/metadata/set?key=foo&value=bar'); + $this->browser->request('GET', '/metadata/set?key=baz&value=qux'); + $this->browser->request('GET', '/metadata/set?key=foo&value=changed'); + + // assertEquals() because MySQL doesn't keep the order of the keys in a JSON column + self::assertEquals(['foo' => 'changed', 'baz' => 'qux'], self::fetchStoredMetadata($this->getClientId())); + self::assertSame(1, self::countStoredMetadata()); + } + + /** + * @test + */ + public function it_removes_metadata(): void + { + $this->browser->request('GET', '/metadata/set?key=foo&value=bar'); + $this->browser->request('GET', '/metadata/set?key=baz&value=qux'); + + $this->browser->request('GET', '/metadata/remove?key=foo'); + self::assertSame(['baz' => 'qux'], self::fetchStoredMetadata($this->getClientId())); + + $this->browser->request('GET', '/metadata/remove?key=baz'); + self::assertSame([], self::fetchStoredMetadata($this->getClientId())); + } + + /** + * @test + */ + public function it_keeps_the_metadata_of_each_visitor_apart(): void + { + $this->browser->request('GET', '/metadata/set?key=foo&value=first'); + $firstClientId = $this->getClientId(); + + $this->browser->getCookieJar()->clear(); + $this->browser->request('GET', '/metadata/set?key=foo&value=second'); + $secondClientId = $this->getClientId(); + + self::assertNotSame($firstClientId, $secondClientId); + self::assertSame(['foo' => 'first'], self::fetchStoredMetadata($firstClientId)); + self::assertSame(['foo' => 'second'], self::fetchStoredMetadata($secondClientId)); + } + + /** + * @test + */ + public function it_does_not_query_the_database_when_the_metadata_is_not_used(): void + { + $this->browser->request('GET', '/metadata/set?key=foo&value=bar'); + + $this->browser->request('GET', '/'); + self::assertResponseIsSuccessful(); + self::assertSame([], self::getExecutedQueries()); + + // Proves that the queries of a request are recorded + $this->browser->request('GET', '/metadata'); + self::assertNotSame([], self::getExecutedQueries()); + } + + /** + * @test + */ + public function it_keeps_the_expiry_of_metadata_between_requests(): void + { + $before = time(); + $this->browser->request('GET', '/metadata/set?key=foo&value=bar&ttl=3600'); + + $stored = self::fetchStoredMetadata($this->getClientId()); + self::assertIsArray($stored); + self::assertSame('bar', $stored['foo']); + + $expires = $stored[Metadata::EXPIRES_KEY]; + self::assertIsArray($expires); + self::assertIsInt($expires['foo']); + self::assertGreaterThanOrEqual($before + 3600, $expires['foo']); + self::assertLessThanOrEqual(time() + 3600, $expires['foo']); + + $this->browser->request('GET', '/metadata'); + self::assertSame(['foo' => 'bar'], $this->getResponseData()); + } + + /** + * @test + */ + public function it_removes_expired_metadata(): void + { + $this->browser->request('GET', '/metadata/set?key=foo&value=bar'); + $clientId = $this->getClientId(); + + self::storeMetadata($clientId, [ + 'foo' => 'bar', + 'baz' => 'qux', + Metadata::EXPIRES_KEY => ['foo' => time() - 1], + ]); + + $this->browser->request('GET', '/metadata'); + + self::assertSame(['baz' => 'qux'], $this->getResponseData()); + self::assertSame(['baz' => 'qux'], self::fetchStoredMetadata($clientId)); + } + + /** + * @test + */ + public function it_keeps_the_row_of_a_concurrent_request_that_stored_it_first(): void + { + $this->browser->request('GET', '/metadata/set-concurrently?key=foo&value=bar'); + + self::assertResponseIsSuccessful(); + self::assertResponseHasCookie(self::COOKIE_NAME); + self::assertSame(['set_by' => 'concurrent request'], self::fetchStoredMetadata($this->getClientId())); + } + + /** + * @return array + */ + private function getResponseData(): array + { + $data = json_decode((string) $this->browser->getResponse()->getContent(), true, 512, \JSON_THROW_ON_ERROR); + self::assertIsArray($data); + + return $data; + } + + /** + * Returns the SQL of the queries that the last request executed, as recorded by DoctrineBundle in debug mode + * + * @return list + */ + private static function getExecutedQueries(): array + { + $debugDataHolder = self::getContainer()->get('test.doctrine.debug_data_holder'); + self::assertInstanceOf(DebugDataHolder::class, $debugDataHolder); + + $queries = []; + foreach ($debugDataHolder->getData() as $connectionQueries) { + self::assertIsArray($connectionQueries); + + foreach ($connectionQueries as $query) { + self::assertIsArray($query); + self::assertIsString($query['sql']); + + $queries[] = $query['sql']; + } + } + + return $queries; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..343a11f --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,42 @@ +remove(Kernel::getVarDir() . '/cache'); + +// DAMA DoctrineTestBundle rolls back what each test writes, so the schema is created once, before it starts its transactions +foreach (['test'] as $environment) { + $kernel = new Kernel($environment, true); + $kernel->boot(); + + $container = $kernel->getContainer(); + + $metadataClass = $container->getParameter('setono_client.metadata_class'); + Assert::string($metadataClass); + Assert::classExists($metadataClass); + + $managerRegistry = $container->get('doctrine'); + Assert::isInstanceOf($managerRegistry, ManagerRegistry::class); + + $manager = $managerRegistry->getManagerForClass($metadataClass); + Assert::isInstanceOf($manager, EntityManagerInterface::class); + + $classes = $manager->getMetadataFactory()->getAllMetadata(); + $schemaTool = new SchemaTool($manager); + $schemaTool->dropSchema($classes); + $schemaTool->createSchema($classes); + + $kernel->shutdown(); +}