Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash of the checkout><TEST_TOKEN>/`, 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)
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion composer-dependency-analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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])
;
15 changes: 11 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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": {
Expand Down
8 changes: 7 additions & 1 deletion phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="tests/bootstrap.php"
colors="true" cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="functional">
<directory>tests/Functional</directory>
</testsuite>
</testsuites>
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
<logging/>
<source>
<include>
Expand Down
100 changes: 100 additions & 0 deletions tests/Functional/App/Controller/ClientController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

declare(strict_types=1);

namespace Setono\ClientBundle\Tests\Functional\App\Controller;

use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\Persistence\ManagerRegistry;
use Setono\Client\Client;
use Setono\ClientBundle\Context\ClientContextInterface;
use Setono\ClientBundle\Entity\MetadataInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Webmozart\Assert\Assert;

final class ClientController
{
/**
* @param class-string<MetadataInterface> $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<object> $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();
}
}
29 changes: 29 additions & 0 deletions tests/Functional/App/EventSubscriber/SkipCookieSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Setono\ClientBundle\Tests\Functional\App\EventSubscriber;

use Setono\ClientBundle\Event\PreStoreCookieEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
* Vetoes the cookie when the request has the skip_cookie query parameter, like an application would do for a visitor
* that has not consented to cookies
*/
final class SkipCookieSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
PreStoreCookieEvent::class => 'skipCookie',
];
}

public function skipCookie(PreStoreCookieEvent $event): void
{
if ($event->request->query->has('skip_cookie')) {
$event->store = false;
}
}
}
114 changes: 114 additions & 0 deletions tests/Functional/App/Kernel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

declare(strict_types=1);

namespace Setono\ClientBundle\Tests\Functional\App;

use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\DBAL\Connection;
use Setono\ClientBundle\SetonoClientBundle;
use Setono\ClientBundle\Tests\Functional\App\Controller\ClientController;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\HttpKernel\Log\Logger;
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;

final class Kernel extends BaseKernel
Comment thread
loevgaard marked this conversation as resolved.
{
use MicroKernelTrait;

public function registerBundles(): iterable
{
yield new FrameworkBundle();
yield new DoctrineBundle();
yield new DAMADoctrineTestBundle();
yield new SetonoClientBundle();
}

public function getProjectDir(): string
{
return __DIR__;
}

public function getCacheDir(): string
{
return self::getVarDir() . '/cache/' . $this->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']);
}
}
Loading
Loading