diff --git a/.gitattributes b/.gitattributes index f7b781fda..fc7972c4f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,6 +19,7 @@ /phpunit-9.xml.dist export-ignore /phpunit-paratest.xml.dist export-ignore /psalm.xml export-ignore +/src/Test/Behat export-ignore /stubs export-ignore /symfony.lock export-ignore /tests export-ignore diff --git a/.github/workflows/bc-check.yml b/.github/workflows/bc-check.yml index e99f34fd0..577d3e7a1 100644 --- a/.github/workflows/bc-check.yml +++ b/.github/workflows/bc-check.yml @@ -22,16 +22,16 @@ jobs: with: fetch-depth: 0 - - name: Install dependencies - uses: ramsey/composer-install@v2 - with: - composer-options: --prefer-dist - - name: Install PHP with extensions. uses: shivammathur/setup-php@v2 with: php-version: "8.5" + - name: Install dependencies + uses: ramsey/composer-install@v2 + with: + composer-options: --prefer-dist + - name: Install roave/backward-compatibility-check. run: composer bin bc-check require roave/backward-compatibility-check diff --git a/.github/workflows/behat-subtree-split.yaml b/.github/workflows/behat-subtree-split.yaml new file mode 100644 index 000000000..c9d4bb642 --- /dev/null +++ b/.github/workflows/behat-subtree-split.yaml @@ -0,0 +1,22 @@ +name: 'Packages Split' + +on: + push: + branches: [2.x] + +jobs: + packages_split: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: danharrin/monorepo-split-github-action@v2.4.5 + env: + GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} + with: + package_directory: 'src/Test/Behat' + repository_organization: 'zenstruck' + repository_name: 'foundry-behat' + user_name: 'nikophil' + user_email: 'nicolas.philippe@mapado.com' + branch: '0.x' diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml new file mode 100644 index 000000000..ae75d2f62 --- /dev/null +++ b/.github/workflows/behat.yml @@ -0,0 +1,145 @@ +name: Behat + +on: + push: + paths: &paths + - .github/workflows/behat.yml + - src/Test/Behat/** + pull_request: + paths: *paths + schedule: + - cron: '0 0 1,16 * *' + +defaults: + run: + shell: bash + working-directory: ./src/Test/Behat + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + behat: + name: Behat - P:${{ matrix.php }}, S:${{ matrix.symfony }}${{ matrix.deps == 'lowest' && ' (lowest)' || '' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ 8.3, 8.5 ] + symfony: [ 6.4.*, 7.4.* ] + deps: [ highest, lowest ] + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: flex + + - name: Add the polyfill compatible with Doctrine ^2.16 + if: ${{ matrix.deps == 'lowest' }} + run: composer require --dev symfony/polyfill-php80:^1.16 --no-update + + - name: Install dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: "src/Test/Behat" + dependency-versions: ${{ matrix.deps }} + composer-options: --prefer-dist + env: + SYMFONY_REQUIRE: ${{ matrix.symfony }} + + - name: "Main test suite: reset DB at scenario level, with Dama support" + run: vendor/bin/behat --colors -vvv + + - name: "Main test suite: reset DB at scenario level, without Dama support" + run: vendor/bin/behat --colors -vvv --profile=main-no-dama + + - name: "Main test suite: reset DB at scenario level, with native Dama extension" + run: vendor/bin/behat --colors -vvv --profile=main-native-dama --tags='~@skip-with-native-dama' + + - name: Manual reset DB + run: vendor/bin/behat --colors -vvv --profile=reset-manual + + - name: Manual reset DB and Dama support + run: vendor/bin/behat --colors -vvv --profile=reset-manual-dama + + - name: Reset DB at feature level + run: vendor/bin/behat --colors -vvv --profile=reset-feature + + - name: Reset DB at feature level with Dama support + run: vendor/bin/behat --colors -vvv --profile=reset-feature-dama + + - name: Reset DB disabled + run: vendor/bin/behat --colors -vvv --profile=reset-disabled + + - name: Overriding built-in step definitions + run: vendor/bin/behat --colors -vvv --profile=override-steps + + phpunit: + name: PHPUnit - P:${{ matrix.php }}, S:${{ matrix.symfony }}${{ matrix.deps == 'lowest' && ' (lowest)' || '' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ 8.3, 8.5 ] + symfony: [ 6.4.*, 7.4.* ] + deps: [ highest, lowest ] + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: flex + + - name: Add the polyfill compatible with Doctrine ^2.16 + if: ${{ matrix.deps == 'lowest' }} + run: composer require --dev symfony/polyfill-php80:^1.16 --no-update + + - name: Install dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: "src/Test/Behat" + dependency-versions: ${{ matrix.deps }} + composer-options: --prefer-dist + env: + SYMFONY_REQUIRE: ${{ matrix.symfony }} + + - name: Run tests + run: vendor/bin/phpunit + + phpstan: + name: PhpStan + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.5 + coverage: none + tools: flex + + - name: Install dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: "src/Test/Behat" + composer-options: --prefer-dist + env: + SYMFONY_REQUIRE: 7.4.* + + - name: Install PHPStan + run: composer bin phpstan install + + - name: Run PHPStan + run: ../../../bin/tools/phpstan/vendor/phpstan/phpstan/phpstan diff --git a/bin/console b/bin/console index 3bd74f042..4f02aa12c 100755 --- a/bin/console +++ b/bin/console @@ -6,13 +6,6 @@ use Zenstruck\Foundry\Tests\Fixture\TestKernel; require_once __DIR__ . '/../tests/bootstrap.php'; -foreach ($argv ?? [] as $i => $arg) { - if (($arg === '--env' || $arg === '-e') && isset($argv[$i + 1])) { - $_ENV['APP_ENV'] = $argv[$i + 1]; - break; - } -} - -$application = new Application(new TestKernel($_ENV['APP_ENV'], true)); +$application = new Application(new TestKernel('test', true)); $application->run(); diff --git a/composer.json b/composer.json index e414a099a..79762284a 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,8 @@ "src/functions.php", "src/Persistence/functions.php", "src/symfony_console.php" - ] + ], + "exclude-from-classmap": ["src/Test/Behat/"] }, "autoload-dev": { "psr-4": { @@ -101,9 +102,6 @@ "allow-contrib": false } }, - "scripts": { - "post-install-cmd": ["@composer bin phpstan install", "@composer bin phpbench install"] - }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/config/persistence.php b/config/persistence.php index d0f25d483..ddd82018c 100644 --- a/config/persistence.php +++ b/config/persistence.php @@ -19,6 +19,7 @@ use Zenstruck\Foundry\Persistence\PersistedObjectsTracker; use Zenstruck\Foundry\Persistence\PersistenceManager; use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager; +use Zenstruck\Foundry\Story\FixtureStoryResolver; return static function(ContainerConfigurator $container): void { $container->services() @@ -34,12 +35,20 @@ ]) ->set('.zenstruck_foundry.command.load_fixtures', LoadFixturesCommand::class) + ->arg('$fixtureStoryResolver', service('.zenstruck_foundry.story.fixture_resolver')) ->arg('$databaseResetters', tagged_iterator('.foundry.persistence.database_resetter')) ->arg('$kernel', service('kernel')) ->tag('console.command', [ 'command' => 'foundry:load-fixtures|foundry:load-stories|foundry:load-story', 'description' => 'Load stories which are marked with #[AsFixture] attribute.', ]) + + ->set('.zenstruck_foundry.story.fixture_resolver', FixtureStoryResolver::class) + ->args([ + abstract_arg('fixtureStories'), + abstract_arg('groupedStories'), + ]) + ->public() ; if (\PHP_VERSION_ID >= 80400) { @@ -48,7 +57,7 @@ ->tag('kernel.event_listener', ['event' => TerminateEvent::class, 'method' => 'refresh']) ->tag('kernel.event_listener', ['event' => ConsoleTerminateEvent::class, 'method' => 'refresh']) ->tag('kernel.event_listener', ['event' => WorkerMessageHandledEvent::class, 'method' => 'refresh']) // @phpstan-ignore class.notFound - ->tag('foundry.hook', ['class' => null, 'method' => 'afterPersistHook', 'event' => AfterPersist::class]) + ->tag('kernel.event_listener', ['method' => 'afterPersistHook', 'event' => AfterPersist::class]) ; } }; diff --git a/docs/index.rst b/docs/index.rst index ede9373c0..ad66ce299 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1634,6 +1634,8 @@ You can use the ``#[WithStory]`` attribute to load stories in your tests: If used on the class, the story will be loaded before each test method. +.. _as-fixture-attribute: + Local Development Fixtures -------------------------- @@ -2725,6 +2727,400 @@ This extension provides the following features: The PHPUnit extension is only compatible with PHPUnit 10+. +Behat Integration +----------------- + +Foundry provides a Behat extension that correctly boots the Foundry for you. + +Installation +~~~~~~~~~~~~ + +1. The Behat extension is shipped as an independent packagist package, so you need to install it in addition to the main Foundry package: + +.. code-block:: terminal + + $ composer require --dev zenstruck/foundry-behat + +2. Enable the Foundry extension in your ``behat.yaml``: + +.. code-block:: yaml + + default: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: ~ + + # SymfonyExtension is required for the Foundry Behat extension to work, so make sure to enable it as well + FriendsOfBehat\SymfonyExtension: ~ + +Database Reset Modes +~~~~~~~~~~~~~~~~~~~~ + +Like in PHPUnit, using the ``ResetDatabase`` attribute, Foundry can be configured to automatically reset the database +in your Behat tests. This is useful to ensure a clean state for your tests. You can configure this in your ``behat.yaml``: + +.. code-block:: yaml + + default: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: scenario # or "feature" or "manual" + +The ``database_reset_mode`` option controls when the database is reset: + +- ``disabled``: Never reset automatically (default) +- ``scenario``: Reset before each scenario +- ``feature``: Reset before each feature file +- ``manual``: Only reset when using the ``@resetDB`` tag + +.. tip:: + + With ``scenario`` mode, you can skip the reset for a specific scenario with the ``@noResetDB`` tag. + + .. code-block:: gherkin + + @noResetDB + Scenario: Keep data from previous scenario + Then 1 contact should exist + +.. tip:: + + When using ``database_reset_mode: manual`` or ``database_reset_mode: feature``, + you can force a reset using the ``@resetDB`` tag: + + .. code-block:: gherkin + + @resetDB + Scenario: Start with fresh database + Then 0 contacts should exist + +DAMA DoctrineTestBundle Support +............................... + +This Behat extension supports `DAMADoctrineTestBundle`_ out of the box. But some features are not compatible with its +native extension, such as the ``@noResetDB`` tag or the ``feature`` and ``manual`` database reset modes. + +To use these features along with DAMA DoctrineTestBundle, enable Foundry's own DAMA support: + +.. code-block:: yaml + + default: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: scenario + enable_dama_support: true + +.. note:: + + When using Foundry's DAMA support, do not enable the native DAMA Behat extension + (``DAMA\DoctrineTestBundle\Behat\ServiceContainer\DoctrineExtension``). + +Built-in Behat Context +~~~~~~~~~~~~~~~~~~~~~~ + +Foundry ships a built-in context with steps definitions, which helps to create objects, access them and make assertions on them. +To use it, add the following to your ``behat.yaml``: + +.. code-block:: yaml + + default: + suites: + main: + contexts: + - Zenstruck\Foundry\Test\Behat\FoundryContext + +Create objects +.............. + +.. code-block:: gherkin + + # Create a single object + # "contact" is a short name resolved from the factory class name (ContactFactory → contact) + Given there is a contact + + # Create a named object: this name will be useful for later reference + Given there is a contact named "john" + + # Create an object with properties + Given there is a contact named "john" with: + | name | email | + | John Doe | john@email.com | + + # Create multiple objects + # Notice that you can use the plural form of the factory name + Given there are contacts with: + | _ref | name | + | A | John Doe | + | B | Jane Doe | + +The ``_ref`` column is a special column that allows you to name the created objects for later reference. + +.. tip:: + + Factory short names and object names can be either quoted or unquoted. Quoting is required when the name + contains spaces (e.g. ``"blog post"``): + + .. code-block:: gherkin + + # both are equivalent + Given there is a "contact" named "john" + Given there is a contact named john + + # quoting is required for multi-word factory names + Given there is a "blog post" named "Foundry rocks" + +.. note:: + + You can use custom names or disambiguate factory names by using the attribute ``#[FactoryShortName]``: + + :: + + use Zenstruck\Foundry\Attribute\FactoryShortName; + + #[FactoryShortName(shortName: 'person', pluralName: 'people')] + final class PersonFactory extends PersistentObjectFactory + {} + +Type handling in table values +............................. + +When using properties in Gherkin tables, Foundry automatically converts string values based on the target property type: + +.. code-block:: gherkin + + Given there is a post named "my-post" with: + | title | category | publishedAt | status | body | + | My Post | tech | 2026-01-15 | published | null | + +- **Object references**: If the property type has a registered factory, Foundry looks up the object by name in the registry + (e.g. ``tech`` resolves to the ``Category`` object named "tech") - see below +- **null**: The literal string ``null`` is converted to PHP ``null`` +- **Booleans**: ``true`` and ``false`` are converted to PHP booleans +- **Dates**: If the property type implements ``DateTimeInterface``, the value is parsed using PHP's native date parsing + (e.g. ``2026-01-15``, ``yesterday``, ``+1 week``) +- **Enums**: If the property type is a ``BackedEnum``, the value is resolved using ``::from()`` + +Referencing objects in another object +..................................... + +.. code-block:: gherkin + + Given there is a category named "tech" + Given there is a post named "my-post" with: + | title | category | + | My Post | tech | + +The property ``Post::$category`` expects a ``Category`` object. Foundry detects this and looks up +the category named "tech" in the registry. + +.. tip:: + + If for any reason the property type cannot be resolved, you can use the special syntax ````: + + .. code-block:: gherkin + + Given there is a category named "tech" + Given there is a post named "my-post" with: + | title | category | + | My Post | | + + The ```` syntax is an escape hatch for edge cases where automatic type resolution fails. + Prefer using automatic resolution (just the object name) whenever possible, as it is cleaner and more readable. + +.. note:: + + Objects can be referenced by their name until the next database reset occurs. How long they persist depends on your + ``database_reset_mode``. In any case, the object registry is always cleared between features, even when using + ``database_reset_mode: disabled`` (the data may still exist in the database, but the named references are lost). + This is also true for the assertions and id access described below. + +Assertions +.......... + +.. code-block:: gherkin + + # count objects of a type + Then 2 contacts should exist + + # assert a specific object has properties + Then contact named "john" should have properties: + | name | + | John Doe | + + # You can also use various natural language forms: + Then the contact named "john" should exist and have properties: + | name | + | John Doe | + + # assert if objects are persisted or not + Then contact named "john" should exist + Then contact named "jane" should not exist + +Accessing ids of created objects +................................ + +.. code-block:: gherkin + + Given there is a contact named "john" + + # Access the last id created + When I am on "/contacts/" + + # Or the last id for specific type: + When I am on "/contacts/" + + # Be even more specific with the name: + When I am on "/contacts/" + +.. warning:: + + The ```` and ```` syntax only work for object which have "simple" ids (integer, string or Uuid). + This means it won't work for objects with `composite keys `_ + nor for `derived entities `_. + +.. note:: + + As for object references, the ids can also be accessed until the next database reset occurs. And they are also cleared after each feature. + +Overriding built-in step definitions +.................................... + +If the built-in step definitions don't fit your need or conflict with your own contexts and step definitions, you can +**re-word** them right from the extension configuration, without writing any PHP. Map each built-in pattern to your own +wording under the ``steps`` key: + +.. code-block:: yaml + + default: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + steps: + # "built-in pattern": "your wording" + 'there is a(n) :factoryShortName named :objectName': 'create a :factoryShortName called :objectName' + 'there are :factoryShortName with:': 'the following :factoryShortName exist:' + +You can now write: + +.. code-block:: gherkin + + Given create a contact called "john" + +The built-in wording is fully replaced: only the patterns you override change, every other built-in step keeps working +(and you automatically benefit from new built-in steps added in the future). + +For larger customizations, you can point to one or several translation catalogues (``xliff``, ``yaml`` or ``php``) instead +of (or in addition to) the inline map: + +.. code-block:: yaml + + default: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + translations: '%paths.base%/tests/behat/foundry-steps.xliff' + +.. code-block:: xml + + + + + + + + there is a(n) :factoryShortName named :objectName + create a :factoryShortName called :objectName + + + + + +.. warning:: + + Your wording must keep the same **placeholders / capturing groups** as the built-in pattern (``:factoryShortName`` + and ``:objectName`` for turnip patterns, ``(?P...)`` and ``(?P...)`` for regex + patterns). Otherwise the step won't be able to call the underlying method. + +.. note:: + + Overrides apply to the language of your ``.feature`` files (the ``# language:`` header, defaulting to ``en``), **not** + to the CLI ``--lang`` option. If your features are written in another language, set the ``locale`` option accordingly: + + .. code-block:: yaml + + Zenstruck\Foundry\Test\Behat\FoundryExtension: + locale: fr + steps: + 'there is a(n) :factoryShortName named :objectName': 'il existe un(e) :factoryShortName nommé :objectName' + +Loading Fixtures with Tags +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Foundry's Behat extension leverages :ref:`the #[AsFixture] attribute <_as-fixture-attribute>` in order to load stories. + +First, mark a Story with the ``#[AsFixture]`` attribute: + +:: + + use Zenstruck\Foundry\Attribute\AsFixture; + use Zenstruck\Foundry\Story; + + #[AsFixture(name: 'my-contacts')] + final class ContactsStory extends Story + { + public function build(): void + { + $this->addState('john', ContactFactory::createOne(['name' => 'John'])); + $this->addState('jane', ContactFactory::createOne(['name' => 'Jane'])); + $this->addState('bob', ContactFactory::createOne(['name' => 'Bob'])); + } + } + +Then use the ``@withFixture`` tag to load a Story before a scenario: + +.. code-block:: gherkin + + @withFixture(my-contacts) + Scenario: View contacts + Then 3 contacts should exist + + Scenario: Assert john exists + # Objects added with "addState()" are automatically available in the objects registry and can be + # referenced in your scenarios. + Then contact named "john" should have properties: + | name | + | John | + +The ``@withFixture`` tag can also be used on a **feature** to load fixtures once for all scenarios in that feature: + +.. code-block:: gherkin + + @withFixture(my-contacts) + Feature: Contacts management + Scenario: List contacts + Then 3 contacts should exist + + Scenario: Find john + Then contact named "john" should exist + +.. tip:: + + When combining ``@withFixture`` with ``@resetDB`` (either on a scenario or inherited from the feature), the fixtures + are automatically reloaded after the database is reset. + + .. code-block:: gherkin + + @withFixture(my-contacts) + Feature: Contacts management + Scenario: List all contacts + Then 3 contacts should exist + + @resetDB + Scenario: Start fresh but still have fixtures + # Database was reset, but fixtures were reloaded + Then 3 contacts should exist + +.. note:: + + ``@withFixture`` also works with Scenario Outlines (``Examples`` tables). + Bundle Configuration -------------------- diff --git a/phpstan.neon b/phpstan.neon index 1fe298d15..3ac64ac3f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -65,6 +65,7 @@ parameters: excludePaths: - config/reference.php (?) + - tests/Fixture/Maker/tmp (?) - tests/Fixture/Maker/expected/can_create_factory_with_auto_activated_not_persisted_option.php - tests/Fixture/Maker/expected/can_create_factory_interactively.php @@ -75,3 +76,5 @@ parameters: - ./src/Maker/Factory/LegacyORMDefaultPropertiesGuesser.php - ./src/Maker/Factory/DoctrineScalarFieldsDefaultPropertiesGuesser.php - ./src/ORM/OrmV2PersistenceStrategy.php + + - ./src/Test/Behat/ diff --git a/phpunit-9.xml.dist b/phpunit-9.xml.dist index 327cb3f02..6dab9a281 100644 --- a/phpunit-9.xml.dist +++ b/phpunit-9.xml.dist @@ -42,6 +42,9 @@ src + + src/Test/Behat + diff --git a/phpunit-paratest.xml.dist b/phpunit-paratest.xml.dist index 0b254377c..5ce50bb3a 100644 --- a/phpunit-paratest.xml.dist +++ b/phpunit-paratest.xml.dist @@ -32,6 +32,9 @@ src + + src/Test/Behat + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 415e87b18..122b7c9f6 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -36,5 +36,8 @@ src + + src/Test/Behat + diff --git a/src/Command/LoadFixturesCommand.php b/src/Command/LoadFixturesCommand.php index 04b7316aa..f9e9e89c1 100644 --- a/src/Command/LoadFixturesCommand.php +++ b/src/Command/LoadFixturesCommand.php @@ -22,7 +22,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\HttpKernel\KernelInterface; use Zenstruck\Foundry\Persistence\ResetDatabase\BeforeFirstTestResetter; -use Zenstruck\Foundry\Story; +use Zenstruck\Foundry\Story\FixtureStoryResolver; /** * @author Nicolas PHILIPPE @@ -32,10 +32,7 @@ final class LoadFixturesCommand extends Command { public function __construct( - /** @var array> */ - private readonly array $stories, - /** @var array>> */ - private readonly array $groupedStories, + private readonly FixtureStoryResolver $fixtureStoryResolver, /** @var iterable */ private iterable $databaseResetters, private KernelInterface $kernel, @@ -46,14 +43,14 @@ public function __construct( protected function configure(): void { $this - ->addArgument('name', InputArgument::OPTIONAL, 'The name of the story to load.') + ->addArgument('name', InputArgument::OPTIONAL, "Story's name or stories group's name to load.") ->addOption('append', 'a', InputOption::VALUE_NONE, 'Skip resetting database and append data to the existing database.') ; } protected function execute(InputInterface $input, OutputInterface $output): int { - if (0 === \count($this->stories)) { + if (!$this->fixtureStoryResolver->hasAnyFixtures()) { throw new LogicException('No story as fixture available: add attribute #[AsFixture] to your story classes before running this command.'); } @@ -69,37 +66,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->resetDatabase(); } - $stories = []; - - if (null === ($name = $input->getArgument('name'))) { - if (1 === \count($this->stories)) { - $name = \array_keys($this->stories)[0]; - } else { - $storyNames = \array_keys($this->stories); - if (\count($this->groupedStories) > 0) { - $storyNames[] = '(choose a group of stories...)'; - } - $name = $io->choice('Choose a story to load:', $storyNames); - } - - if (!isset($this->stories[$name])) { - $groupsNames = \array_keys($this->groupedStories); - $name = $io->choice('Choose a group of stories:', $groupsNames); - } - } + $fixtureNameOrGroup = $input->getArgument('name') ?? $this->getNameWhenNotProvided($io); - if (isset($this->stories[$name])) { - $io->comment("Loading story with name \"{$name}\"..."); - $stories = [$name => $this->stories[$name]]; - } + $stories = $this->fixtureStoryResolver->resolve($fixtureNameOrGroup); - if (isset($this->groupedStories[$name])) { - $io->comment("Loading stories group \"{$name}\"..."); - $stories = $this->groupedStories[$name]; + if (!$stories) { + throw new InvalidArgumentException("Story with name or group \"{$fixtureNameOrGroup}\" does not exist."); } - if (!$stories) { - throw new InvalidArgumentException("Story with name \"{$name}\" does not exist."); + if ($this->fixtureStoryResolver->hasFixture($fixtureNameOrGroup)) { + $io->comment("Loading story with name \"{$fixtureNameOrGroup}\"..."); + } else { + $io->comment("Loading stories group \"{$fixtureNameOrGroup}\"..."); } foreach ($stories as $name => $storyClass) { @@ -126,4 +104,24 @@ private function resetDatabase(): void $databaseResetter->resetBeforeFirstTest($this->kernel); } } + + private function getNameWhenNotProvided(SymfonyStyle $io): string + { + if ($this->fixtureStoryResolver->hasOnlyOneFixture()) { + return $this->fixtureStoryResolver->availableFixtureNames()[0]; + } + + $storyNames = $this->fixtureStoryResolver->availableFixtureNames(); + if (\count($this->fixtureStoryResolver->availableGroupNames()) > 0) { + $storyNames[] = '(choose a group of stories...)'; + } + $name = $io->choice('Choose a story to load:', $storyNames); + + if (!$this->fixtureStoryResolver->hasFixture($name)) { + $groupsNames = $this->fixtureStoryResolver->availableGroupNames(); + $name = $io->choice('Choose a group of stories:', $groupsNames); + } + + return $name; + } } diff --git a/src/DependencyInjection/AsFixtureStoryCompilerPass.php b/src/DependencyInjection/AsFixtureStoryCompilerPass.php index 03599e638..3df748d8a 100644 --- a/src/DependencyInjection/AsFixtureStoryCompilerPass.php +++ b/src/DependencyInjection/AsFixtureStoryCompilerPass.php @@ -20,7 +20,7 @@ final class AsFixtureStoryCompilerPass implements CompilerPassInterface { public function process(ContainerBuilder $container): void { - if (!$container->has('.zenstruck_foundry.command.load_fixtures')) { + if (!$container->has('.zenstruck_foundry.story.fixture_resolver')) { return; } @@ -58,8 +58,8 @@ public function process(ContainerBuilder $container): void throw new LogicException("Cannot use #[AsFixture] group(s) \"{$collisionNames}\", they collide with fixture names."); } - $container->findDefinition('.zenstruck_foundry.command.load_fixtures') - ->setArgument('$stories', $fixtureStories) + $container->findDefinition('.zenstruck_foundry.story.fixture_resolver') + ->setArgument('$fixtureStories', $fixtureStories) ->setArgument('$groupedStories', $groupedFixtureStories); } } diff --git a/src/Maker/Factory/NoPersistenceObjectsAutoCompleter.php b/src/Maker/Factory/NoPersistenceObjectsAutoCompleter.php index 307b6c179..f0f9adc60 100644 --- a/src/Maker/Factory/NoPersistenceObjectsAutoCompleter.php +++ b/src/Maker/Factory/NoPersistenceObjectsAutoCompleter.php @@ -13,6 +13,8 @@ /** * @internal + * + * todo: ⚠️ this class is problematic and should be at least refactored or removed */ final class NoPersistenceObjectsAutoCompleter { @@ -38,6 +40,11 @@ public function getAutocompleteValues(): array continue; } + if (\str_contains($phpFile->getRealPath(), 'var/cache') + || \str_contains($phpFile->getRealPath(), 'src/Test/Behat')) { + continue; + } + $class = $this->toPSR4($rootPath, $phpFile, $namespacePrefix); if (\in_array($class, ['Zenstruck\Foundry\Proxy', 'Zenstruck\Foundry\RepositoryProxy', 'Zenstruck\Foundry\RepositoryAssertions'])) { diff --git a/src/Persistence/PersistedObjectsTracker.php b/src/Persistence/PersistedObjectsTracker.php index 51126459c..75db6f14c 100644 --- a/src/Persistence/PersistedObjectsTracker.php +++ b/src/Persistence/PersistedObjectsTracker.php @@ -23,7 +23,7 @@ final class PersistedObjectsTracker /** * This buffer of objects needs to be static to be kept between two kernel.reset events. * - * @var \WeakMap keys: objects, values: value ids + * @var \WeakMap> keys: objects, values: value ids */ private static \WeakMap $trackedObjects; diff --git a/src/Persistence/PersistenceManager.php b/src/Persistence/PersistenceManager.php index aafc2b87b..e91813a8a 100644 --- a/src/Persistence/PersistenceManager.php +++ b/src/Persistence/PersistenceManager.php @@ -30,8 +30,9 @@ * @author Kevin Bond * * @internal + * @final */ -final class PersistenceManager +class PersistenceManager { private bool $flush = true; private bool $persist = true; @@ -417,7 +418,10 @@ public function resetDatabaseManager(): ResetDatabaseManager return $this->resetDatabaseManager; } - public function getIdentifierValues(object $object): mixed + /** + * @return array + */ + public function getIdentifierValues(object $object): array { return $this->strategyFor($object::class)->getIdentifierValues($object); } diff --git a/src/Story.php b/src/Story.php index 70ea4c776..72bfc8ee3 100644 --- a/src/Story.php +++ b/src/Story.php @@ -16,6 +16,7 @@ use Zenstruck\Foundry\Persistence\Exception\RefreshObjectFailed; use Zenstruck\Foundry\Persistence\Proxy; use Zenstruck\Foundry\Persistence\ProxyGenerator; +use Zenstruck\Foundry\Story\Event\StateAddedToStory; /** * @author Kevin Bond @@ -120,6 +121,10 @@ final protected function addState(string $name, mixed $value, ?string $pool = nu $this->state[$name] = $value; + if (\is_object($value) && Configuration::instance()->hasEventDispatcher()) { + Configuration::instance()->eventDispatcher()->dispatch(new StateAddedToStory($value, $name)); + } + if ($pool) { $this->addToPool($pool, $value); } diff --git a/src/Story/Event/StateAddedToStory.php b/src/Story/Event/StateAddedToStory.php new file mode 100644 index 000000000..c74a77a00 --- /dev/null +++ b/src/Story/Event/StateAddedToStory.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Story\Event; + +/** + * @internal + * + * @template T of object + */ +final class StateAddedToStory +{ + public function __construct( + public readonly object $object, + public readonly string $name, + ) { + } +} diff --git a/src/Story/FixtureStoryNotFound.php b/src/Story/FixtureStoryNotFound.php new file mode 100644 index 000000000..475020c34 --- /dev/null +++ b/src/Story/FixtureStoryNotFound.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Story; + +/** + * @internal + * + * @author Nicolas PHILIPPE + */ +final class FixtureStoryNotFound extends \RuntimeException +{ + /** + * @param list $availableFixtures + */ + public static function forNameOrGroup(string $fixtureName, array $availableFixtures): self + { + $message = "Fixture story with name or group \"{$fixtureName}\" not found:"; + + if ($availableFixtures) { + $message .= ' Available fixtures: '.\implode(', ', $availableFixtures); + } else { + $message .= ' No fixture stories are registered. Add #[AsFixture] attribute to your Story classes.'; + } + + return new self($message); + } + + /** + * @param list $availableGroups + */ + public static function forGroup(string $groupName, array $availableGroups): self + { + $message = "Fixture story group \"{$groupName}\" not found:"; + + if ($availableGroups) { + $message .= ' Available groups: '.\implode(', ', $availableGroups); + } else { + $message .= ' No fixture story groups are registered.'; + } + + return new self($message); + } +} diff --git a/src/Story/FixtureStoryResolver.php b/src/Story/FixtureStoryResolver.php new file mode 100644 index 000000000..510868253 --- /dev/null +++ b/src/Story/FixtureStoryResolver.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Story; + +use Zenstruck\Foundry\Story; + +/** + * @internal + * + * @author Nicolas PHILIPPE + */ +final class FixtureStoryResolver +{ + public function __construct( + /** @var array> */ + private readonly array $fixtureStories, + /** @var array>> */ + private readonly array $groupedStories = [], + ) { + } + + /** + * @return array> + * + * @throws FixtureStoryNotFound + */ + public function resolve(string $fixtureOrGroupName): array + { + if ($this->hasFixture($fixtureOrGroupName)) { + return [$fixtureOrGroupName => $this->fixtureStories[$fixtureOrGroupName]]; + } + + if ($this->hasGroup($fixtureOrGroupName)) { + return $this->resolveGroup($fixtureOrGroupName); + } + + throw FixtureStoryNotFound::forNameOrGroup($fixtureOrGroupName, [...$this->availableFixtureNames(), ...$this->availableGroupNames()]); + } + + public function hasAnyFixtures(): bool + { + return \count($this->fixtureStories) > 0; + } + + public function hasFixture(string $name): bool + { + return isset($this->fixtureStories[$name]); + } + + public function hasOnlyOneFixture(): bool + { + return 1 === \count($this->fixtureStories); + } + + /** + * @return list + */ + public function availableFixtureNames(): array + { + return \array_keys($this->fixtureStories); + } + + /** + * @return list + */ + public function availableGroupNames(): array + { + return \array_keys($this->groupedStories); + } + + /** + * @return array> + * + * @throws FixtureStoryNotFound + */ + private function resolveGroup(string $groupName): array + { + if (!isset($this->groupedStories[$groupName])) { + throw FixtureStoryNotFound::forGroup($groupName, $this->availableGroupNames()); + } + + return $this->groupedStories[$groupName]; + } + + private function hasGroup(string $name): bool + { + return isset($this->groupedStories[$name]); + } +} diff --git a/src/Test/Behat/.env b/src/Test/Behat/.env new file mode 100644 index 000000000..27cb0691b --- /dev/null +++ b/src/Test/Behat/.env @@ -0,0 +1,4 @@ +DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" +MONGO_URL="" +USE_DAMA_DOCTRINE_TEST_BUNDLE="1" +USE_PHP_84_LAZY_OBJECTS="1" diff --git a/src/Test/Behat/.gitattributes b/src/Test/Behat/.gitattributes new file mode 100644 index 000000000..87ff4750b --- /dev/null +++ b/src/Test/Behat/.gitattributes @@ -0,0 +1,8 @@ +* text=auto + +/.gitattributes export-ignore +/.gitignore export-ignore +/behat.yml export-ignore +/features export-ignore +/symfony.lock export-ignore +/tests export-ignore diff --git a/src/Test/Behat/.gitignore b/src/Test/Behat/.gitignore new file mode 100644 index 000000000..6c328ae65 --- /dev/null +++ b/src/Test/Behat/.gitignore @@ -0,0 +1,6 @@ +/composer.lock +/vendor/ +/var/ +/.env.local +/.phpunit.cache/ +/config/reference.php diff --git a/src/Test/Behat/README.md b/src/Test/Behat/README.md new file mode 100644 index 000000000..23e48db79 --- /dev/null +++ b/src/Test/Behat/README.md @@ -0,0 +1,7 @@ +# Behat extension for Foundry + +This is a subpackage of [Foundry](https://github.com/zenstruck/foundry). + +Please open any issue or pull request on the main repository. + +See docs at: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#behat-integration diff --git a/src/Test/Behat/behat.yml b/src/Test/Behat/behat.yml new file mode 100644 index 000000000..fd5898395 --- /dev/null +++ b/src/Test/Behat/behat.yml @@ -0,0 +1,135 @@ +default: + testers: + stop_on_failure: true + + gherkin: + cache: var/cache/gherkin + + extensions: + Yceruto\BehatExtension\Extension\ExceptionExtension: ~ + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: scenario + enable_dama_support: true + Behat\MinkExtension: + sessions: + symfony: + symfony: ~ + FriendsOfBehat\SymfonyExtension: + bootstrap: tests/bootstrap.php + kernel: + class: Zenstruck\Foundry\Test\Behat\Tests\Fixture\BehatTestKernel + + suites: + main: + paths: [features/main] + contexts: &common_contexts + - Behat\MinkExtension\Context\MinkContext + - Zenstruck\Foundry\Test\Behat\FoundryContext + - Zenstruck\Foundry\Test\Behat\Tests\Fixture\TestFoundryContext + +main-no-dama: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: scenario + enable_dama_support: false + + suites: + main: false + main-no-dama: + paths: [features/main] + contexts: *common_contexts + +main-native-dama: + extensions: + DAMA\DoctrineTestBundle\Behat\ServiceContainer\DoctrineExtension: ~ + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: scenario + enable_dama_support: false + + suites: + main: false + main-native-dama: + paths: [features/main] + contexts: *common_contexts + +reset-manual: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: manual + enable_dama_support: false + + suites: + main: false + reset-manual: + paths: [features/reset-manual] + contexts: *common_contexts + +reset-manual-dama: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: manual + enable_dama_support: true + + suites: + main: false + reset-manual-dama: + paths: [features/reset-manual] + contexts: *common_contexts + +reset-feature: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: feature + enable_dama_support: false + + suites: + main: false + reset-feature: + paths: [features/reset-feature] + contexts: *common_contexts + + +reset-feature-dama: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: feature + enable_dama_support: true + + suites: + main: false + reset-feature-dama: + paths: [features/reset-feature] + contexts: *common_contexts + +reset-disabled: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: disabled + enable_dama_support: false + + suites: + main: false + reset-disabled: + paths: [features/reset-disabled] + contexts: + - Behat\MinkExtension\Context\MinkContext + - Zenstruck\Foundry\Test\Behat\FoundryContext + - Zenstruck\Foundry\Test\Behat\Tests\Fixture\TestFoundryContext + - Zenstruck\Foundry\Test\Behat\Tests\Fixture\ResetDisabledTestContext + +override-steps: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: scenario + enable_dama_support: true + steps: + 'there is a(n) :factoryShortName named :objectName': 'create a :factoryShortName called :objectName' + 'there are :factoryShortName with:': 'the following :factoryShortName exist:' + # transformations (#[Transform]) go through the same translator: they can be re-worded too + '/(.*)(.*)/': '/(.*)\[lastId\](.*)/' + + suites: + main: false + override-steps: + paths: [features/override-steps] + contexts: *common_contexts diff --git a/src/Test/Behat/bin/console b/src/Test/Behat/bin/console new file mode 100755 index 000000000..46a950573 --- /dev/null +++ b/src/Test/Behat/bin/console @@ -0,0 +1,18 @@ +#!/usr/bin/env php + $arg) { + if (($arg === '--env' || $arg === '-e') && isset($argv[$i + 1])) { + $_ENV['APP_ENV'] = $argv[$i + 1]; + break; + } +} + +$application = new Application(new BehatTestKernel($_ENV['APP_ENV'], true)); + +$application->run(); diff --git a/src/Test/Behat/composer.json b/src/Test/Behat/composer.json new file mode 100644 index 000000000..2f3239d95 --- /dev/null +++ b/src/Test/Behat/composer.json @@ -0,0 +1,74 @@ +{ + "name": "zenstruck/foundry-behat", + "type": "behat-extension", + "description": "Behat extension for zenstruck/foundry.", + "homepage": "https://github.com/zenstruck/foundry", + "license": "MIT", + "keywords": ["fixture", "factory", "test", "symfony", "dev", "behat"], + "authors": [ + { + "name": "Kevin Bond", + "email": "kevinbond@gmail.com" + }, + { + "name": "Nicolas PHILIPPE", + "email": "nikophil@gmail.com" + } + ], + "autoload": { + "psr-4": { + "Zenstruck\\Foundry\\Test\\Behat\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Zenstruck\\Foundry\\Test\\Behat\\Tests\\": "tests", + "Zenstruck\\Foundry\\Tests\\Fixture\\": "../../../tests/Fixture" + } + }, + "require": { + "php": "^8.1", + "behat/behat": "^3.22", + "friends-of-behat/symfony-extension": "^2.0", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", + "symfony/string": "^6.4|^7.0", + "zenstruck/foundry": "dev-behat-extension" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.9", + "behat/mink-browserkit-driver": "^2.0", + "dama/doctrine-test-bundle": "^8.6", + "doctrine/common": "^3.2.2", + "doctrine/doctrine-bundle": "^2.10|^3.0", + "doctrine/orm": "^2.16|^3.0", + "friends-of-behat/mink-extension": "^2.0", + "phpunit/phpunit": "^12.5", + "symfony/dotenv": "^6.4|^7.0", + "symfony/flex": "^2.10", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", + "yceruto/behat-extension": "^1.0.2" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "allow-plugins": { + "symfony/flex": true, + "bamarni/composer-bin-plugin": true + } + }, + "extra": { + "symfony": { + "allow-contrib": false + }, + "bamarni-bin": { + "target-directory": "../../../bin/tools", + "forward-command": false + } + }, + "scripts": { + "post-install-cmd": ["./symlink-vendor.sh"], + "post-update-cmd": ["./symlink-vendor.sh"] + } +} diff --git a/src/Test/Behat/config/behat.php b/src/Test/Behat/config/behat.php new file mode 100644 index 000000000..2e653741b --- /dev/null +++ b/src/Test/Behat/config/behat.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Zenstruck\Foundry\Persistence\Event\AfterPersist; +use Zenstruck\Foundry\Story\Event\StateAddedToStory; +use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; +use Zenstruck\Foundry\Test\Behat\FoundryContext; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; + +return static function(ContainerConfigurator $container): void { + $container->services() + ->set('.zenstruck_foundry.behat.factory_resolver', FactoryShortNameResolver::class) + ->args([ + tagged_iterator('foundry.factory'), + ]) + ->public() + + ->set('.zenstruck_foundry.behat.object_registry', ObjectRegistry::class) + ->args([ + service('.zenstruck_foundry.behat.factory_resolver'), + service('.zenstruck_foundry.persistence_manager'), + ]) + ->tag('kernel.event_listener', ['method' => 'storeLastId', 'event' => AfterPersist::class]) + ->tag('kernel.event_listener', ['method' => 'storeAfterStateAddedToStory', 'event' => StateAddedToStory::class]) + ->public() + + ->set(FoundryContext::class, FoundryContext::class) + ->args([ + service('.zenstruck_foundry.behat.factory_resolver'), + service('.zenstruck_foundry.behat.object_registry'), + ]) + ->public() + ->autowire() + ->autoconfigure() + ; +}; diff --git a/src/Test/Behat/features/main/access-uuid.feature b/src/Test/Behat/features/main/access-uuid.feature new file mode 100644 index 000000000..430707890 --- /dev/null +++ b/src/Test/Behat/features/main/access-uuid.feature @@ -0,0 +1,22 @@ +Feature: Test accessing Uuid-based entity ids + + Scenario: Can create and reference Uuid entity + Given there is an "entity with uid" named "the object" + Then "entity with uid" named "the object" should exist + + Scenario: Can access last id for Uuid entity via lastId transform + Given there is an "entity with uid" named "A" + # The transform resolves the Uuid to its RFC 4122 string format. + # We just need to verify the transform doesn't throw, the 404 is expected. + When I am on "/" + Then the response status code should be 404 + + Scenario: Can access last id for specific Uuid entity type + Given there is an "entity with uid" named "B" + When I am on "/" + Then the response status code should be 404 + + Scenario: Can access id from reference for Uuid entity + Given there is an "entity with uid" named "my-uid-entity" + When I am on "/" + Then the response status code should be 404 diff --git a/src/Test/Behat/features/main/create-objects.feature b/src/Test/Behat/features/main/create-objects.feature new file mode 100644 index 000000000..e640b4e93 --- /dev/null +++ b/src/Test/Behat/features/main/create-objects.feature @@ -0,0 +1,189 @@ +Feature: Test objects creation + + Scenario: Can create entity with properties via PyTable + Given there is a contact named A with: + | name | + | John Doe | + Then 1 contact should exist + Then contact named A should have properties: + | name | + | John Doe | + + Scenario: Can create one entity with two lines in the PyTable (!) + Given there is a contact named A with: + | name | + | John Doe | + | Jane Doe | + Then an "InvalidArgumentException" exception should be thrown containing message "Expected exactly one line of properties to create one object, got 2 lines" + + Scenario: Can create entity with properties via PyTable (!) + Given there is a "i don't exist" + Then an "FactoryNotResolvable" exception should be thrown containing message "Cannot resolve factory for name \"i don't exist\"" + + Scenario: Multiple objects created with the same reference is handled (!) + Given there is a contact named A + And there is a contact named A + Then an "ObjectAlreadyRegistered" exception should be thrown containing message "Object \"A\" is already registered" + + Scenario: Reference to a non existent objet handled (!) + Then contact named "I don't exist" should have properties: + | foo | + | bar | + Then an "ObjectNotFound" exception should be thrown containing message "Object \"contact I don't exist\" was not found" + + Scenario: Invalid property name handled (!) + Given there is a contact named A with: + | foo | + | bar | + Then an "InvalidArgumentException" exception should be thrown containing message "Cannot set attribute \"foo\" for object" + + Scenario: Can create multiple entities via PyTable + Then 0 contacts should exist + Given there are contacts with: + | _ref | name | + | A | John Doe | + | B | Jane Doe | + Then 2 contacts should exist + Then contact named A should have properties: + | name | + | John Doe | + Then contact named B should have properties: + | name | + | Jane Doe | + + Scenario: Multiple objects created within a table with the same reference is handled (!) + Given there are contacts with: + | _ref | name | + | A | John Doe | + | A | Jane Doe | + Then an "ObjectAlreadyRegistered" exception should be thrown containing message "Object \"A\" is already registered" + + Scenario: Can reference another object + Given there is a category named MyCategory + And there is an address named "the address" + And there is a contact named A with: + | name | category | address | + | John Doe | | | + When I am on "/" + Then contact named A should have properties: + | name | category | address | + | John Doe | | | + Then 1 contact should exist + Then 1 category should exist + Then 1 address should exist + + Scenario: Can reference another object with short syntax + Given there is a category named MyCategory + And there is an address named "the address" + And there is a contact named A with: + | name | category | address | + | John Doe | MyCategory | the address | + When I am on "/" + Then contact named A should have properties: + | name | category | address | + | John Doe | MyCategory | the address | + + Scenario: Can reference object with date + Given there is a "generic entity" named "GE" with: + | prop1 | propInteger | date | dateMutable | bool | float | stringEnum | intEnum | + | foo | 1 | 2026-01-01 | 2026-01-02 | false | 3.14 | some_value | 0 | + When I am on "/" + Then "generic entity" named "GE" should have properties: + | prop1 | propInteger | date | dateMutable | bool | float | stringEnum | intEnum | + | foo | 1 | 2026-01-01 | 2026-01-02 | false | 3.14 | some_value | 0 | + + Scenario: Wrong assertion on string correctly handled (!) + Given there is a "generic entity" named "GE" with: + | prop1 | + | foo | + Then "generic entity" named "GE" should have properties: + | prop1 | + | bar | + Then an "AssertionFailedError" exception should be thrown matching pattern "/foo(.*)bar/" + + Scenario: Wrong assertion on string correctly handled (!) + Given there is a "generic entity" named "GE" with: + | prop1 | propInteger | + | foo | 1 | + Then "generic entity" named "GE" should have properties: + | propInteger | + | 42 | + Then an "AssertionFailedError" exception should be thrown matching pattern "/1(.*)42/" + + Scenario: Wrong assertion on date correctly handled (!) + Given there is a "generic entity" named "GE" with: + | prop1 | date | + | foo | 2026-01-01 | + Then "generic entity" named "GE" should have properties: + | date | + | 2026-01-02 | + Then an "AssertionFailedError" exception should be thrown matching pattern "/2026/" + + Scenario: Wrong assertion on bool correctly handled (!) + Given there is a "generic entity" named "GE" with: + | prop1 | bool | + | foo | true | + Then "generic entity" named "GE" should have properties: + | bool | + | false | + Then an "AssertionFailedError" exception should be thrown matching pattern "/true(.*)false/" + + Scenario: Wrong assertion on bool correctly handled (!) + Given there is a "generic entity" named "GE" with: + | prop1 | bool | + | foo | false | + Then "generic entity" named "GE" should have properties: + | bool | + | true | + Then an "AssertionFailedError" exception should be thrown matching pattern "/false(.*)true/" + + Scenario: Wrong assertion on enum correctly handled (!) + Given there is a "generic entity" named "GE" with: + | prop1 | stringEnum | + | foo | some_value | + Then "generic entity" named "GE" should have properties: + | stringEnum | + | other_value | + Then an "AssertionFailedError" exception should be thrown matching pattern "/StringBackedEnum/" + + Scenario: Can compare null + Given there is a "generic entity" named "GE" with: + | prop1 | bool | + | foo | null | + When I am on "/" + Then "generic entity" named "GE" should have properties: + | prop1 | bool | + | foo | null | + + Scenario: Wrong assertion with null works (!) + Given there is a "generic entity" named "GE" with: + | prop1 | bool | + | foo | null | + When I am on "/" + Then "generic entity" named "GE" should have properties: + | prop1 | bool | + | foo | "null" | + Then an "AssertionFailedError" exception should be thrown matching pattern "/null(.*)null/" + + Scenario: Wrong assertion with null works in the other way (!) + Given there is a "generic entity" named "GE" with: + | prop1 | date | + | foo | 2026-01-01 | + When I am on "/" + Then "generic entity" named "GE" should have properties: + | prop1 | date | + | foo | null | + Then an "AssertionFailedError" exception should be thrown matching pattern "/DateTimeImmutable(.*)null/" + + Scenario: Can use a factory with disambiguated name + Given there is a "tag2" + Then 1 tag2 should exist + + Scenario: Can use a factory with changed name & plural + Given there is a "child of contact" + And there is a "child of contact" + Then 2 "children of contact" should exist + + Scenario: Cannot use a factory with ambiguous name (!) + Given there is a "tag" + Then an "FactoryNotResolvable" exception should be thrown containing message "Multiple factories found for name \"tag\"" diff --git a/src/Test/Behat/features/main/no-reset-db.feature b/src/Test/Behat/features/main/no-reset-db.feature new file mode 100644 index 000000000..9851db900 --- /dev/null +++ b/src/Test/Behat/features/main/no-reset-db.feature @@ -0,0 +1,15 @@ +@skip-with-native-dama +Feature: Skip database reset with @noResetDB tag + + Scenario: First scenario creates data + Given there is a contact + Then 1 contact should exist + + @noResetDB + Scenario: Data persists with @noResetDB tag + Then 1 contact should exist + Given there is a contact + Then 2 contacts should exist + + Scenario: Normal reset resumes after @noResetDB + Then 0 contacts should exist diff --git a/src/Test/Behat/features/main/object-names-with-digits.feature b/src/Test/Behat/features/main/object-names-with-digits.feature new file mode 100644 index 000000000..89ae6a370 --- /dev/null +++ b/src/Test/Behat/features/main/object-names-with-digits.feature @@ -0,0 +1,21 @@ +Feature: Test object names starting with digits + + Scenario: Quoted object names can start with digits + Given there is a "generic entity" named "123" with: + | prop1 | + | foo | + Then "generic entity" named "123" should exist + And "generic entity" named "123" should have properties: + | prop1 | + | foo | + + Scenario: Quoted object names can be pure numbers + Given there is a contact named "007" + Then contact named "007" should exist + + Scenario: Count assertions still work correctly alongside digit names + Given there is a contact named "1" + And there is a contact named "2" + Then 2 contacts should exist + And contact named "1" should exist + And contact named "2" should exist diff --git a/src/Test/Behat/features/main/persist-entities.feature b/src/Test/Behat/features/main/persist-entities.feature new file mode 100644 index 000000000..08633a4de --- /dev/null +++ b/src/Test/Behat/features/main/persist-entities.feature @@ -0,0 +1,79 @@ +Feature: Test persisting entities + + Scenario: View homepage + When I am on "/" + Then the response status code should be 200 + Then I should see "Hello World" + + Scenario: Can persist entities + # Can name entities + Given there is a contact named A + # Can create unnamed entities + And there is a contact + When I am on "/" + Then the response status code should be 200 + Then I should see "Hello World" + Then 2 contacts should exist + + Scenario: Can visit pages twice and still access to EM + Given there is a contact + When I am on "/" + Then I should see "Hello World" + When I am on "/" + Then I should see "Hello World" + Then 1 contact should exist + + Scenario Outline: Persist entity + Given there is a contact + When I am on "/" + Then the response status code should be 200 + Then I should see "" + Then 1 contact should exist + + Examples: + | data | + | Hello | + | World | + + Scenario: Can access last created entity ID + Given there is a "generic entity" named "the object" with: + | prop1 | + | foo | + When I am on "/orm/update//bar" + Then the response status code should be 200 + Then "generic entity" named "the object" should have properties: + | prop1 | + | bar | + + Scenario: Throws if last id is not found (!) + When I am on "/orm/update//bar" + Then an "RuntimeException" exception should be thrown containing message "No last id found" + + Scenario: Can access last created entity ID + Given there is a "generic entity" named "the object" with: + | prop1 | + | foo | + And there is a contact + When I am on "/orm/update//bar" + Then the response status code should be 200 + Then "generic entity" named "the object" should have properties: + | prop1 | + | bar | + + Scenario: Throws if last id is not found (!) + When I am on "/orm/update//bar" + Then an "InvalidArgumentException" exception should be thrown containing message "No object of type \"generic entity\" found" + + Scenario: Can access an ID from reference + Given there is a "generic entity" named "the object" with: + | prop1 | + | foo | + When I am on "/orm/update//bar" + Then the response status code should be 200 + Then "generic entity" named "the object" should have properties: + | prop1 | + | bar | + + Scenario: Throws if the reference is not found (!) + When I am on "/orm/update//bar" + Then an "ObjectNotFound" exception should be thrown containing message "Object \"generic entity the object\" was not found" diff --git a/src/Test/Behat/features/main/with-fixture-on-feature.feature b/src/Test/Behat/features/main/with-fixture-on-feature.feature new file mode 100644 index 000000000..f343f3183 --- /dev/null +++ b/src/Test/Behat/features/main/with-fixture-on-feature.feature @@ -0,0 +1,9 @@ +@withFixture(behat-contacts) +Feature: Test @withFixture tag + + Scenario: Load behat-contacts fixture with @withFixture tag + Given there is a contact named "jane-doe" + Then 2 contacts should exist + + Scenario: Ensure DB is fresh + Then 1 contact should exist diff --git a/src/Test/Behat/features/main/with-fixture.feature b/src/Test/Behat/features/main/with-fixture.feature new file mode 100644 index 000000000..e0a800e72 --- /dev/null +++ b/src/Test/Behat/features/main/with-fixture.feature @@ -0,0 +1,47 @@ +Feature: Test @withFixture tag + + @withFixture(behat-contacts) + Scenario: Load behat-contacts fixture with @withFixture tag + Then 1 contact should exist + + Scenario: Ensure DB is fresh + Then 0 contact should exist + + @withFixture(behat-contacts) + Scenario Outline: Works with scenario outline + When I am on "/" + Then the response status code should be 200 + Then I should see "" + Then 1 contact should exist + + Examples: + | data | + | Hello | + | World | + + @withFixture(behat-contacts) + Scenario: Can access entities from fixture + Then 1 contact should exist + Then contact named "john-doe" should have properties: + | name | + | John Doe | + + @withFixture(behat-category) + Scenario: Can use entities from fixture in another entity + Given there is a contact named "jane-doe" with: + | name | category | + | Jane Doe | category fixture | + Then 1 contact should exist + Then contact named "jane-doe" should have properties: + | name | category | + | Jane Doe | category fixture | + + @withFixture(behat-category) @withFixture(behat-contacts) + Scenario: Can load multiple fixtures + Then 1 contact should exist + Then 2 categories should exist + + @withFixture(behat-stories) + Scenario: Can load grouped fixtures + Then 1 contact should exist + Then 2 categories should exist diff --git a/src/Test/Behat/features/override-steps/override-steps.feature b/src/Test/Behat/features/override-steps/override-steps.feature new file mode 100644 index 000000000..a1e7f8646 --- /dev/null +++ b/src/Test/Behat/features/override-steps/override-steps.feature @@ -0,0 +1,21 @@ +Feature: Overriding built-in step definitions + + Scenario: Re-worded single creation step + Given create a contact called "john" + Then 1 contact should exist + Then contact named "john" should exist + + Scenario: Re-worded table creation step keeps placeholders and table normalization + Given the following contacts exist: + | _ref | name | + | A | John Doe | + | B | Jane Doe | + Then 2 contacts should exist + Then contact named A should have properties: + | name | + | John Doe | + + Scenario: Re-worded id transform (#[Transform] patterns can be overridden too) + Given create a contact called "john" + When I am on "/[lastId]" + Then the response status code should be 404 diff --git a/src/Test/Behat/features/reset-disabled/manual-isolation-1.feature b/src/Test/Behat/features/reset-disabled/manual-isolation-1.feature new file mode 100644 index 000000000..a689c5d44 --- /dev/null +++ b/src/Test/Behat/features/reset-disabled/manual-isolation-1.feature @@ -0,0 +1,16 @@ +Feature: No database isolation (disabled mode) - Part 1 + + Scenario: First scenario creates data + Given there is a contact named A + Then 1 contact should exist + + Scenario: Second scenario sees previous data (no reset) + Then 1 contact should exist + Then contact named A should exist + Given there is a contact named B + Then 2 contacts should exist + + Scenario: Third scenario sees all accumulated data + Then 2 contacts should exist + Then contact named A should exist + Then contact named B should exist diff --git a/src/Test/Behat/features/reset-disabled/manual-isolation-2.feature b/src/Test/Behat/features/reset-disabled/manual-isolation-2.feature new file mode 100644 index 000000000..570ce0644 --- /dev/null +++ b/src/Test/Behat/features/reset-disabled/manual-isolation-2.feature @@ -0,0 +1,13 @@ +# resetDB should have no effect +@resetDB +Feature: No database isolation (disabled mode) - Part 2 + + Scenario: Contacts still exist from previous feature + Then 2 contacts should exist + + # ObjectRegistry is reset between features even when isolation is disabled + Then contact named A should not exist + + @resetDB + Scenario: DB should be reset + Then 2 contacts should exist diff --git a/src/Test/Behat/features/reset-feature/isolation.feature b/src/Test/Behat/features/reset-feature/isolation.feature new file mode 100644 index 000000000..b834e21cd --- /dev/null +++ b/src/Test/Behat/features/reset-feature/isolation.feature @@ -0,0 +1,24 @@ +Feature: Database isolation per feature - Part 1 + + Scenario: First scenario creates data + Given there is a contact named A with: + | name | + | John Doe | + Then 1 contact should exist + + Scenario: Second scenario sees first scenario's data (no reset within feature) + Then 1 contact should exist + + Scenario: Third scenario also sees accumulated data + Given there is a contact named B with: + | name | + | Jane Doe | + Then 2 contacts should exist + + Scenario: Fourth scenario can access objects created in previous scenarios via ObjectRegistry + Then contact named A should have properties: + | name | + | John Doe | + And contact named B should have properties: + | name | + | Jane Doe | diff --git a/src/Test/Behat/features/reset-feature/isolation2.feature b/src/Test/Behat/features/reset-feature/isolation2.feature new file mode 100644 index 000000000..b56acd026 --- /dev/null +++ b/src/Test/Behat/features/reset-feature/isolation2.feature @@ -0,0 +1,18 @@ +Feature: Database isolation per feature - Part 2 + + Scenario: First scenario of new feature should have empty database (reset between features) + Then 0 contacts should exist + + Scenario: Second scenario in new feature sees first scenario's data + Given there is a "contact" named "C" with: + | name | + | Alice Doe | + Then 1 contact should exist + + Scenario: Third scenario confirms data persists within feature + Then 1 contact should exist + + Scenario: Could access data created in previous scenario + Then "contact" named "C" should have properties: + | name | + | Alice Doe | diff --git a/src/Test/Behat/features/reset-feature/reset-db.feature b/src/Test/Behat/features/reset-feature/reset-db.feature new file mode 100644 index 000000000..5f1064646 --- /dev/null +++ b/src/Test/Behat/features/reset-feature/reset-db.feature @@ -0,0 +1,22 @@ +Feature: Manual database reset with @resetDB tag + + Scenario: Ensure fresh DB + Then 0 contact should exist + + Scenario: Create one contact + Given there is a contact named A + Then 1 contact should exist + + Scenario: Ensure contact still exists + Then 1 contact should exist + Then contact named A should exist + + @resetDB + Scenario: Database is reset with @resetDB tag + Then 0 contacts should exist + Then contact named A should not exist + Given there is a contact + Then 1 contact should exist + + Scenario: Data from tagged scenario persists + Then 1 contact should exist diff --git a/src/Test/Behat/features/reset-feature/with-fixture-on-feature.feature b/src/Test/Behat/features/reset-feature/with-fixture-on-feature.feature new file mode 100644 index 000000000..714bd7ac5 --- /dev/null +++ b/src/Test/Behat/features/reset-feature/with-fixture-on-feature.feature @@ -0,0 +1,17 @@ +@withFixture(behat-contacts) +Feature: "@withFixture" on feature + + Scenario: Ensure fixture is loaded + Then 1 contact should exist + + Scenario: Ensure fixture is loaded once + Then 1 contact should exist + + Scenario: Can add new data + Given there is a contact + Then 2 contacts should exist + + @resetDB + Scenario: Reset DB should clear DB and reload fixture + Then 1 contact should exist + diff --git a/src/Test/Behat/features/reset-feature/with-fixture-on-scenario.feature b/src/Test/Behat/features/reset-feature/with-fixture-on-scenario.feature new file mode 100644 index 000000000..d981813b7 --- /dev/null +++ b/src/Test/Behat/features/reset-feature/with-fixture-on-scenario.feature @@ -0,0 +1,20 @@ +Feature: "@withFixture" on scenario + + Scenario: Ensure DB is fresh + Then 0 contacts should exist + + @withFixture(behat-contacts) + Scenario: Ensure fixture is loaded + Then 1 contact should exist + + Scenario: Ensure fixture is loaded once + Then 1 contact should exist + + Scenario: Can add new data + Given there is a contact + Then 2 contacts should exist + + @resetDB + Scenario: Reset DB should clear DB + Then 0 contacts should exist + diff --git a/src/Test/Behat/features/reset-manual/manual-isolation-1.feature b/src/Test/Behat/features/reset-manual/manual-isolation-1.feature new file mode 100644 index 000000000..7cf95c3d9 --- /dev/null +++ b/src/Test/Behat/features/reset-manual/manual-isolation-1.feature @@ -0,0 +1,17 @@ +@resetDB +Feature: Manual database isolation (disabled mode) - Part 1 + + Scenario: First scenario creates data + Given there is a contact named A + Then 1 contact should exist + + Scenario: Second scenario sees previous data (no reset) + Then 1 contact should exist + Then contact named A should exist + Given there is a contact named B + Then 2 contacts should exist + + Scenario: Third scenario sees all accumulated data + Then 2 contacts should exist + Then contact named A should exist + Then contact named B should exist diff --git a/src/Test/Behat/features/reset-manual/manual-isolation-2.feature b/src/Test/Behat/features/reset-manual/manual-isolation-2.feature new file mode 100644 index 000000000..67fb81523 --- /dev/null +++ b/src/Test/Behat/features/reset-manual/manual-isolation-2.feature @@ -0,0 +1,15 @@ +Feature: Manual database isolation (disabled mode) - Part 2 + + Scenario: Contacts still exist from previous feature + Then 2 contacts should exist + + # ObjectRegistry is reset between features even when isolation is disabled + Then contact named A should not exist + + @resetDB + Scenario: DB should be reset + Then 0 contacts should exist + + Scenario: Create another contact + Given there is a contact + Then 1 contact should exist diff --git a/src/Test/Behat/features/reset-manual/manual-isolation-3.feature b/src/Test/Behat/features/reset-manual/manual-isolation-3.feature new file mode 100644 index 000000000..a622ef9e3 --- /dev/null +++ b/src/Test/Behat/features/reset-manual/manual-isolation-3.feature @@ -0,0 +1,12 @@ +@resetDB +Feature: Manual database isolation (disabled mode) - Part 3 + + Scenario: Ensure DB is fresh + Then 0 contacts should exist + + Scenario: Create a contact + Given there is a contact + Then 1 contacts should exist + + Scenario: Ensure contact still exists + Then 1 contacts should exist diff --git a/src/Test/Behat/features/reset-manual/with-fixture-on-feature.feature b/src/Test/Behat/features/reset-manual/with-fixture-on-feature.feature new file mode 100644 index 000000000..d2ec7c330 --- /dev/null +++ b/src/Test/Behat/features/reset-manual/with-fixture-on-feature.feature @@ -0,0 +1,18 @@ +@resetDB +@withFixture(behat-contacts) +Feature: "@withFixture" on feature + + Scenario: Ensure fixture is loaded + Then 1 contact should exist + + Scenario: Ensure fixture is loaded once + Then 1 contact should exist + + Scenario: Can add new data + Given there is a contact + Then 2 contacts should exist + + @resetDB + Scenario: Reset DB should clear DB and reload fixture + Then 1 contact should exist + diff --git a/src/Test/Behat/features/reset-manual/with-fixture-on-scenario.feature b/src/Test/Behat/features/reset-manual/with-fixture-on-scenario.feature new file mode 100644 index 000000000..ff171fa4b --- /dev/null +++ b/src/Test/Behat/features/reset-manual/with-fixture-on-scenario.feature @@ -0,0 +1,23 @@ +@resetDB +Feature: "@withFixture" on scenario + + Scenario: Ensure DB is fresh + Then 0 contacts should exist + + @withFixture(behat-contacts) + Scenario: Ensure fixture is loaded + Then 1 contact should exist + + Scenario: Ensure fixture is loaded once + Then 1 contact should exist + + Scenario: Can add new data + Given there is a contact + Then 2 contacts should exist + + @resetDB + Scenario: Reset DB should clear DB + Then 0 contacts should exist + Given there is a contact + Then 1 contact should exist + diff --git a/src/Test/Behat/phpstan.neon b/src/Test/Behat/phpstan.neon new file mode 100644 index 000000000..3f119d58a --- /dev/null +++ b/src/Test/Behat/phpstan.neon @@ -0,0 +1,33 @@ +includes: + - phar://phpstan.phar/conf/bleedingEdge.neon + +parameters: + level: 8 + + treatPhpDocTypesAsCertain: false + inferPrivatePropertyTypeFromConstructor: true + checkUninitializedProperties: true + checkMissingCallableSignature: true + + paths: + - ./config + - ./src + - ./tests + + banned_code: + non_ignorable: false + + bootstrapFiles: + - ./vendor/autoload.php + + ignoreErrors: + # prevent PHPStan to force to type data providers + - identifier: missingType.iterableValue + path: tests/ + + # not relevant for files outside from Foundry's namespace + - identifier: classConstant.internalClass + path: config/ + + excludePaths: + - config/reference.php (?) diff --git a/src/Test/Behat/phpunit.dist.xml b/src/Test/Behat/phpunit.dist.xml new file mode 100644 index 000000000..a51ccbc11 --- /dev/null +++ b/src/Test/Behat/phpunit.dist.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + tests + + + + + + src + + + + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::delegateTriggerToBackend + trigger_deprecation + + + + + + + + + + diff --git a/src/Test/Behat/src/Attribute/FactoryShortName.php b/src/Test/Behat/src/Attribute/FactoryShortName.php new file mode 100644 index 000000000..52ce092ac --- /dev/null +++ b/src/Test/Behat/src/Attribute/FactoryShortName.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Attribute; + +/** + * @author Nicolas PHILIPPE + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +final class FactoryShortName +{ + public function __construct( + public readonly string $shortName, + public readonly ?string $pluralName = null, + ) { + } +} diff --git a/src/Test/Behat/src/DatabaseResetMode.php b/src/Test/Behat/src/DatabaseResetMode.php new file mode 100644 index 000000000..f3f1b22c3 --- /dev/null +++ b/src/Test/Behat/src/DatabaseResetMode.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat; + +/** + * @internal + */ +enum DatabaseResetMode: string +{ + case DISABLED = 'disabled'; + case MANUAL = 'manual'; + case SCENARIO = 'scenario'; + case FEATURE = 'feature'; +} diff --git a/src/Test/Behat/src/DependencyInjection/BehatServicesCompilerPass.php b/src/Test/Behat/src/DependencyInjection/BehatServicesCompilerPass.php new file mode 100644 index 000000000..c9aaf7f5a --- /dev/null +++ b/src/Test/Behat/src/DependencyInjection/BehatServicesCompilerPass.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\DependencyInjection; + +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class BehatServicesCompilerPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->has('behat.service_container')) { + // we're not in a Behat context + return; + } + + $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__, 2).'/config')); + $loader->load('behat.php'); + } +} diff --git a/src/Test/Behat/src/Exception/DamaNativeExtensionIncompatibility.php b/src/Test/Behat/src/Exception/DamaNativeExtensionIncompatibility.php new file mode 100644 index 000000000..8d00182b9 --- /dev/null +++ b/src/Test/Behat/src/Exception/DamaNativeExtensionIncompatibility.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Exception; + +final class DamaNativeExtensionIncompatibility extends \LogicException +{ + public static function withManualResetDbMode(): self + { + return new self( + 'Database reset mode "manual" is not supported the native Behat extension for "dama/doctrine-test-bundle" is enabled. Please enable Foundry\'s DAMA support with "enable_dama_support: true" and disable the native extension to enable manual database reset with DAMA support.' + ); + } + + public static function withFeatureResetDbMode(): self + { + return new self( + 'Database reset mode "feature" is not supported the native Behat extension for "dama/doctrine-test-bundle" is enabled. Please enable Foundry\'s DAMA support with "enable_dama_support: true" and disable the native extension to enable automatic database reset at feature level with DAMA support.' + ); + } + + public static function withFoundryDamaSupport(): self + { + return new self('Foundry\'s Dama support cannot be enabled when the native Behat extension for "dama/doctrine-test-bundle" is enabled.'); + } + + public static function withNoResetDbTag(): self + { + return new self('Cannot use "@noResetDB" with native Behat extension for "dama/doctrine-test-bundle".'); + } +} diff --git a/src/Test/Behat/src/Exception/FactoryNotResolvable.php b/src/Test/Behat/src/Exception/FactoryNotResolvable.php new file mode 100644 index 000000000..24c7c3976 --- /dev/null +++ b/src/Test/Behat/src/Exception/FactoryNotResolvable.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Exception; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class FactoryNotResolvable extends \RuntimeException +{ + public static function forName(string $name): self + { + return new self("Cannot resolve factory for name \"{$name}\": short name does not exist"); + } + + /** + * @param list $factories + */ + public static function conflict(string $name, array $factories): self + { + return new self(\sprintf( + 'Multiple factories found for name "%s": %s. Use #[FactoryShortName] to disambiguate.', + $name, + \implode(', ', $factories) + )); + } +} diff --git a/src/Test/Behat/src/Exception/InvalidObjectParameter.php b/src/Test/Behat/src/Exception/InvalidObjectParameter.php new file mode 100644 index 000000000..aa27e8ec9 --- /dev/null +++ b/src/Test/Behat/src/Exception/InvalidObjectParameter.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Exception; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class InvalidObjectParameter extends \RuntimeException +{ + public static function objectReferencedInTableDoesNotExist(string $column, ObjectNotFound $previous): self + { + return new self("A reference to an object cannot be resolved in the table, at column \"{$column}\": {$previous->getMessage()}", previous: $previous); + } + + public static function invalidDate(string $column, string $invalidDate, \Throwable $previous): self + { + return new self("Invalid date given \"{$invalidDate}\", at column \"{$column}\"", previous: $previous); + } + + public static function invalidEnumValue(string $column, string $invalidEnumValue): self + { + return new self("Invalid enum value given \"{$invalidEnumValue}\", at column \"{$column}\""); + } +} diff --git a/src/Test/Behat/src/Exception/InvalidResetDbTag.php b/src/Test/Behat/src/Exception/InvalidResetDbTag.php new file mode 100644 index 000000000..3d568c765 --- /dev/null +++ b/src/Test/Behat/src/Exception/InvalidResetDbTag.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Exception; + +use Behat\Behat\EventDispatcher\Event\BeforeFeatureTested; +use Behat\Behat\EventDispatcher\Event\BeforeScenarioTested; + +final class InvalidResetDbTag extends \LogicException +{ + public function __construct(string $message, BeforeFeatureTested|BeforeScenarioTested $event) + { + $file = $event->getFeature()->getFile(); + + if (!$file) { + parent::__construct($message); + + return; + } + + $suite = $event->getEnvironment()->getSuite(); + if ($suite->hasSetting('paths')) { + foreach ($suite->getSetting('paths') as $path) { + if (!$path) { + continue; + } + + if (\str_contains($file, $path)) { + $file = \mb_substr($file, \mb_strpos($file, $path)); // @phpstan-ignore argument.type (strpos cannot be false if $path is contained in $file) + break; + } + } + } + + $errorFileAndLine = match ($event::class) { + BeforeFeatureTested::class => "{$file}:{$event->getFeature()->getLine()}", + BeforeScenarioTested::class => "{$file}:{$event->getScenario()->getLine()}", + }; + + parent::__construct("{$message}\nAt {$errorFileAndLine}"); + } + + public static function bothTagsUsed(BeforeScenarioTested $event): self + { + return new self('Cannot use both "@resetDB" and "@noResetDB" tags at the same time.', $event); + } + + public static function resetDbWithScenarioMode(BeforeFeatureTested|BeforeScenarioTested $event): self + { + return new self('Cannot use "@resetDB" tag with database_reset_mode set as "scenario".', $event); + } + + public static function resetDbOnFeatureWithFeatureMode(BeforeFeatureTested $event): self + { + return new self('Cannot use "@resetDB" tag on a feature with database_reset_mode set as "feature".', $event); + } + + public static function resetDbOnScenarioWithScenarioMode(BeforeScenarioTested $event): self + { + return new self('Cannot use "@resetDB" tag on a scenario with database_reset_mode set as "scenario".', $event); + } + + public static function noResetDbWithManualMode(BeforeFeatureTested|BeforeScenarioTested $event): self + { + return new self('Cannot use "@noResetDB" tag with database_reset_mode set as "manual".', $event); + } + + public static function noResetDbWithFeatureMode(BeforeFeatureTested|BeforeScenarioTested $event): self + { + return new self('Cannot use "@noResetDB" with database_reset_mode set as "feature".', $event); + } +} diff --git a/src/Test/Behat/src/Exception/ObjectAlreadyRegistered.php b/src/Test/Behat/src/Exception/ObjectAlreadyRegistered.php new file mode 100644 index 000000000..5c35f2a59 --- /dev/null +++ b/src/Test/Behat/src/Exception/ObjectAlreadyRegistered.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Exception; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class ObjectAlreadyRegistered extends \RuntimeException +{ + /** @param class-string $objectClass */ + public static function forClassAndName(string $objectClass, string $name): self + { + return new self("Object \"{$name}\" is already registered for class \"{$objectClass}\". This may happen when loading multiple Stories in a group that define objects with the same name."); + } +} diff --git a/src/Test/Behat/src/Exception/ObjectNotFound.php b/src/Test/Behat/src/Exception/ObjectNotFound.php new file mode 100644 index 000000000..72c6817c8 --- /dev/null +++ b/src/Test/Behat/src/Exception/ObjectNotFound.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Exception; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class ObjectNotFound extends \RuntimeException +{ + public static function forFactoryAndName(string $factoryShortName, string $name): self + { + return new self("Object \"{$factoryShortName} {$name}\" was not found."); + } + + /** + * @param class-string $objectName + */ + public static function forClassAndName(string $objectName, string $name): self + { + return new self("Object of class \"{$objectName}\" with name \"{$name}\" was not found."); + } +} diff --git a/src/Test/Behat/src/Exception/UnsupportedTranslationResource.php b/src/Test/Behat/src/Exception/UnsupportedTranslationResource.php new file mode 100644 index 000000000..e0def1f25 --- /dev/null +++ b/src/Test/Behat/src/Exception/UnsupportedTranslationResource.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Exception; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class UnsupportedTranslationResource extends \InvalidArgumentException +{ + public static function forPath(string $path): self + { + return new self("Cannot register step translations from \"{$path}\": only \"xliff\", \"yaml\" and \"php\" files are supported."); + } +} diff --git a/src/Test/Behat/src/FactoryShortNameResolver.php b/src/Test/Behat/src/FactoryShortNameResolver.php new file mode 100644 index 000000000..2d7f549bb --- /dev/null +++ b/src/Test/Behat/src/FactoryShortNameResolver.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat; + +use Symfony\Component\String\Inflector\EnglishInflector; +use Zenstruck\Foundry\Factory; +use Zenstruck\Foundry\ObjectFactory; +use Zenstruck\Foundry\Test\Behat\Attribute\FactoryShortName; +use Zenstruck\Foundry\Test\Behat\Exception\FactoryNotResolvable; + +use function Symfony\Component\String\u; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class FactoryShortNameResolver +{ + /** + * @var array>> + */ + private array $factoryMap = []; + + /** @var array */ + private array $classToShortName = []; + + /** + * @param iterable> $factories + */ + public function __construct(iterable $factories) + { + $inflector = new EnglishInflector(); + + foreach ($factories as $factory) { + if (!$factory instanceof ObjectFactory) { + continue; + } + + $shortName = $this->shortNameFor($factory::class); + + // we allow multiple factories to have the same shortName: + // we'll only trigger an error when trying to access an unambiguous shortname + // we don't want to force the user to resolve all potential conflicts at startup. + $this->factoryMap[$shortName] ??= []; + $this->factoryMap[$shortName][] = $factory; + + $plural = \mb_strtolower($this->factoryShortNameAttribute($factory::class)->pluralName ?? $inflector->pluralize($shortName)[0]); + $this->factoryMap[$plural] ??= []; + $this->factoryMap[$plural][] = $factory; + + $this->classToShortName[$factory::class()] = $shortName; + } + } + + /** + * @return ObjectFactory + * + * @throws FactoryNotResolvable + */ + public function factoryFor(string $shortName): ObjectFactory + { + $normalized = \mb_strtolower($shortName); + + if (!isset($this->factoryMap[$normalized])) { + throw FactoryNotResolvable::forName($shortName); + } + + $factories = $this->factoryMap[$normalized]; + + if (\count($factories) > 1) { + throw FactoryNotResolvable::conflict($shortName, \array_map(static fn(ObjectFactory $f) => $f::class, $factories)); + } + + return $factories[0]::new(); + } + + /** + * @return class-string + */ + public function targetObjectClassFor(string $shortName): string + { + return $this->factoryFor($shortName)::class(); + } + + /** + * @param class-string $className + */ + public function hasFactoryForClass(string $className): bool + { + return isset($this->classToShortName[$className]); + } + + /** + * @param class-string $className + */ + public function getShortNameForClass(string $className): string + { + return $this->classToShortName[$className] ?? throw new \LogicException("No factory found for class \"{$className}\"."); + } + + /** + * @param class-string> $factoryClass + */ + private function shortNameFor(string $factoryClass): string + { + $attribute = $this->factoryShortNameAttribute($factoryClass); + + if ($attribute) { + return \mb_strtolower($attribute->shortName); + } + + $shortClass = u((new \ReflectionClass($factoryClass))->getShortName()); + + if ($shortClass->endsWith('Factory')) { + $shortClass = $shortClass->slice(0, -7); + } + + return $shortClass + ->snake() + ->replace('_', ' ') + ->lower() + ->toString(); + } + + /** + * @param class-string> $factoryClass + */ + private function factoryShortNameAttribute(string $factoryClass): ?FactoryShortName + { + $reflection = new \ReflectionClass($factoryClass); + + $attributes = $reflection->getAttributes(FactoryShortName::class); + + return ($attributes[0] ?? null)?->newInstance(); + } +} diff --git a/src/Test/Behat/src/FoundryCallFilter.php b/src/Test/Behat/src/FoundryCallFilter.php new file mode 100644 index 000000000..9acea3032 --- /dev/null +++ b/src/Test/Behat/src/FoundryCallFilter.php @@ -0,0 +1,215 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat; + +use Behat\Behat\Definition\Call\DefinitionCall; +use Behat\Gherkin\Node\ExampleTableNode; +use Behat\Gherkin\Node\TableNode; +use Behat\Testwork\Call\Call; +use Behat\Testwork\Call\Filter\CallFilter; +use Symfony\Component\HttpKernel\KernelInterface; +use Zenstruck\Foundry\Test\Behat\Exception\InvalidObjectParameter; +use Zenstruck\Foundry\Test\Behat\Exception\ObjectNotFound; + +/** + * @internal + * + * Transforms TableNodes into FoundryTableNodes where all types are resolved + */ +final class FoundryCallFilter implements CallFilter +{ + private readonly FactoryShortNameResolver $factoryResolver; + private readonly ObjectRegistry $objectRegistry; + + public function __construct( + KernelInterface $symfonyKernel, + ) { + $this->factoryResolver = $symfonyKernel->getContainer()->get('.zenstruck_foundry.behat.factory_resolver'); // @phpstan-ignore assign.propertyType + $this->objectRegistry = $symfonyKernel->getContainer()->get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore assign.propertyType + } + + public function supportsCall(Call $call): bool + { + return array_any( + $call->getArguments(), + static fn($argument) => $argument instanceof TableNode && !$argument instanceof ExampleTableNode + ); + } + + public function filterCall(Call $call): Call + { + if ( + !$call instanceof DefinitionCall + || !$call->getCallee()->getReflection() instanceof \ReflectionMethod + || FoundryContext::class !== $call->getCallee()->getReflection()->class + ) { + return $call; + } + + $arguments = $call->getArguments(); + + if (!isset($arguments['factoryShortName'])) { + throw new \InvalidArgumentException(<<getEnvironment(), + $call->getFeature(), + $call->getStep(), + $call->getCallee(), + \array_map( + fn(mixed $argument) => match ($argument instanceof TableNode) { + true => $this->normalizeObjectParameters($argument, $arguments['factoryShortName']), + false => $argument, + }, + $arguments + ), + $call->getErrorReportingLevel(), + ); + } + + private function normalizeObjectParameters(TableNode $tableNode, string $factoryShortName): TableNode + { + $table = $tableNode->getTable(); + + $headKey = \array_key_first($table); + $thead = \array_shift($table) ?? throw new \LogicException('Table has no header row.'); + + return FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + \Closure::bind( + fn() => $this->maxLineLength, + $tableNode, + TableNode::class + )(), + [ // @phpstan-ignore argument.type (TableNode has the same problem: array $table is not really lists) + $headKey => $thead, // @phpstan-ignore array.invalidKey + ...\array_map( + fn(array $parameters) => $this->normalizeTableRow($parameters, $thead, $factoryShortName), + $table + ), + ] + ); + } + + /** + * @param list $parameters + * @param list $thead + * + * @return array + */ + private function normalizeTableRow(array $parameters, array $thead, string $factoryShortName): array + { + $normalized = []; + foreach ($parameters as $key => $value) { + if (!isset($thead[$key])) { + throw new \LogicException("Table has no column for parameter \"{$key}\". This should never happen, table integrity is checked in TableNode."); + } + + $propertyName = $thead[$key]; + + if ('_ref' === $propertyName) { + $normalized['_ref'] = $value; + + continue; + } + + $normalized[$propertyName] = match (true) { + 'null' === $value => null, + 'true' === $value => true, + 'false' === $value => false, + default => $this->resolveExplicitObjectReference($propertyName, $value) + ?? $this->resolveObjectReferenceBasedOnPropertyType($propertyName, $value, $factoryShortName), + }; + } + + return $normalized; + } + + private function resolveExplicitObjectReference(string $propertyName, string $value): ?object + { + if (!\preg_match('/^[^,]+), (?[^)]+)\)>$/', $value, $matches)) { + return null; + } + + try { + return $this->objectRegistry->getByFactoryShortName($matches['factoryShortName'], $matches['objectName']); + } catch (ObjectNotFound $e) { + throw InvalidObjectParameter::objectReferencedInTableDoesNotExist($propertyName, $e); + } + } + + private function resolveObjectReferenceBasedOnPropertyType(string $propertyName, string $value, string $factoryShortName): mixed + { + $targetClass = $this->factoryResolver->targetObjectClassFor($factoryShortName); + $expectedTypeClass = $this->getPropertyTypeIfClass(new \ReflectionClass($targetClass), $propertyName); + + if (!$expectedTypeClass) { + return $value; + } + + if ($this->factoryResolver->hasFactoryForClass($expectedTypeClass)) { + try { + return $this->objectRegistry->getByObjectClass($expectedTypeClass, $value); + } catch (ObjectNotFound $e) { + throw InvalidObjectParameter::objectReferencedInTableDoesNotExist($propertyName, $e); + } + } + + if (\is_a($expectedTypeClass, \DateTimeInterface::class, allow_string: true)) { + try { + return new $expectedTypeClass($value); + } catch (\Throwable $e) { // @phpstan-ignore catch.neverThrown + throw InvalidObjectParameter::invalidDate($propertyName, $value, $e); + } + } + + if (\is_a($expectedTypeClass, \BackedEnum::class, allow_string: true)) { + $value = \is_numeric($value) ? (int) $value : $value; + + return $expectedTypeClass::tryFrom($value) ?? throw InvalidObjectParameter::invalidEnumValue($propertyName, (string) $value); + } + + throw new \LogicException("Cannot normalize parameter \"{$propertyName}\" with value \"{$value}\"."); + } + + /** + * @param \ReflectionClass $class + * + * @return class-string|null + */ + private function getPropertyTypeIfClass(\ReflectionClass $class, string $propertyName): ?string + { + try { + $property = $class->getProperty($propertyName); + } catch (\ReflectionException) { + if ($class = $class->getParentClass()) { + return $this->getPropertyTypeIfClass($class, $propertyName); + } + } + + if ( + !isset($property) + || !($type = $property->getType()) instanceof \ReflectionNamedType + || $type->isBuiltin() + || !\class_exists($type->getName()) + ) { + return null; + } + + return $type->getName(); + } +} diff --git a/src/Test/Behat/src/FoundryContext.php b/src/Test/Behat/src/FoundryContext.php new file mode 100644 index 000000000..3bafc83ca --- /dev/null +++ b/src/Test/Behat/src/FoundryContext.php @@ -0,0 +1,192 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat; + +use Behat\Behat\Context\Context; +use Behat\Gherkin\Node\TableNode; +use Behat\Step\Given; +use Behat\Step\Then; +use Behat\Transformation\Transform; +use Zenstruck\Assert; +use Zenstruck\Foundry\Configuration; +use Zenstruck\Foundry\Factory; +use Zenstruck\Foundry\ObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Persistence\RepositoryAssertions; + +use function Zenstruck\Foundry\get; +use function Zenstruck\Foundry\Persistence\refresh; + +/** + * @author Nicolas PHILIPPE + * + * @phpstan-import-type Parameters from Factory + * + * @internal + */ +final class FoundryContext implements Context +{ + public function __construct( + private readonly FactoryShortNameResolver $factoryResolver, + private readonly ObjectRegistry $objectRegistry, + ) { + } + + #[Given('there is a(n) :factoryShortName')] + #[Given('there is a(n) :factoryShortName named :objectName')] + public function createObject(string $factoryShortName, ?string $objectName = null): void + { + $this->resolveFactory($factoryShortName, $objectName)->create(); + } + + #[Given('there is a(n) :factoryShortName with:')] + #[Given('there is a(n) :factoryShortName named :objectName with:')] + public function createObjectWithProperties(TableNode $table, string $factoryShortName, ?string $objectName = null): void + { + $factory = $this->resolveFactory($factoryShortName, $objectName); + $parametersList = $table->getColumnsHash(); + + if (1 !== \count($parametersList)) { + throw new \InvalidArgumentException(\sprintf('Expected exactly one line of properties to create one object, got %d lines. Use "there are %s with:" to create multiple objects.', \count($parametersList), $factoryShortName)); + } + + $factory->create($parametersList[0]); + } + + #[Given('there are :factoryShortName with:')] + public function createObjectsWithProperties(TableNode $table, string $factoryShortName): void + { + $parametersList = $table->getColumnsHash(); + + foreach ($parametersList as $parameters) { + $objectName = $parameters['_ref'] ?? null; + unset($parameters['_ref']); + + $this->resolveFactory($factoryShortName, $objectName) + ->create($parameters); + } + } + + #[Then('/^(\d+) "([^"]*)" should exist$/')] + #[Then('/^(\d+) ([^"]*) should exist$/')] + public function assertNbObjectsExist(int $nb, string $factoryShortName): void + { + $this->repositoryAssertionFor($factoryShortName) + ->count($nb); + } + + /** + * Captures: + * - factoryShortName: quoted or unquoted factory/entity name + * - objectName: quoted or unquoted object reference + * + * Supports optional articles ("the", "a", "an"), optional "exist and", + * and optional trailing "properties". + */ + #[Then('/^(?:the |an? )?(?|"(?P[^"]+)"|(?!\d)(?P\S+)) named (?|"(?P[^"]+)"|(?P\S+)) should (?:exist and )?have(?: properties)?:$/')] + public function assertObjectHasProperties(FoundryTableNode $table, string $factoryShortName, string $objectName): void + { + $parametersList = $table->getColumnsHash(); + + if (1 !== \count($parametersList)) { + throw new \InvalidArgumentException(\sprintf('Expected exactly one line of properties for assertion, got %d lines.', \count($parametersList))); + } + + $object = $this->objectRegistry->getByFactoryShortName($factoryShortName, $objectName); + + if (!Configuration::autoRefreshWithLazyObjectsIsEnabled()) { + refresh($object); + } + + foreach ($parametersList[0] as $key => $valueExpected) { + $actualValue = get($object, $key); + + match (true) { + $valueExpected instanceof \DateTimeInterface => Assert::that($actualValue) + ->isInstanceOf(\DateTimeInterface::class) + ->and($actualValue->format('Y-m-d H:i:s')) + ->is($valueExpected->format('Y-m-d H:i:s')), + + \is_object($valueExpected) => Assert::that($actualValue)->is($valueExpected), + + default => Assert::that($actualValue)->equals($valueExpected), + }; + } + } + + #[Then('/^(?:the |an? )?(?|"(?P[^"]+)"|(?!\d)(?P\S+)) named (?|"(?P[^"]+)"|(?P\S+)) should exist$/')] + public function assertObjectExists(string $factoryShortName, string $objectName): void + { + Assert::that( + $this->objectRegistry->has( + $this->factoryResolver->targetObjectClassFor($factoryShortName), + $objectName + ) + )->is(true, "Object with name \"{$objectName}\" of type \"{$factoryShortName}\" does not exist although it should."); + } + + #[Then('/^(?:the |an? )?(?|"(?P[^"]+)"|(?!\d)(?P\S+)) named (?|"(?P[^"]+)"|(?P\S+)) should not exist$/')] + public function assertObjectDoesNotExist(string $factoryShortName, string $objectName): void + { + Assert::that( + $this->objectRegistry->has( + $this->factoryResolver->targetObjectClassFor($factoryShortName), + $objectName + ) + )->is(false, "Object with name \"{$objectName}\" of type \"{$factoryShortName}\" exists although it should not."); + } + + #[Transform('/(.*)(.*)/')] + public function transformLastId(string $before, string $after): string + { + return "{$before}{$this->objectRegistry->lastId()}{$after}"; + } + + #[Transform('/(.*)(.*)/')] + public function transformLastIdForSpecificObject(string $before, string $factoryShortName, string $after): string + { + return "{$before}{$this->objectRegistry->lastIdFor($factoryShortName)}{$after}"; + } + + #[Transform('/(.*)(.*)/')] + public function transformIdForSpecificObject(string $before, string $factoryShortName, string $objectName, string $after): string + { + return "{$before}{$this->objectRegistry->idFor($factoryShortName, $objectName)}{$after}"; + } + + /** + * @return ObjectFactory + */ + private function resolveFactory(string $factoryShortName, ?string $objectName = null): ObjectFactory + { + $factory = $this->factoryResolver->factoryFor($factoryShortName); + + if (!$objectName) { + return $factory; + } + + return $factory->afterInstantiate( + fn(object $object) => $this->objectRegistry->store($object, $objectName) + ); + } + + private function repositoryAssertionFor(string $factoryShortName): RepositoryAssertions + { + $factory = $this->factoryResolver->factoryFor($factoryShortName); + + if (!$factory instanceof PersistentObjectFactory) { + throw new \LogicException(\sprintf('Cannot make assertions with factory of class "%s" with short name "%s": it does not extend "%s".', $factory::class, $factoryShortName, PersistentObjectFactory::class)); + } + + return $factory::assert(); + } +} diff --git a/src/Test/Behat/src/FoundryExtension.php b/src/Test/Behat/src/FoundryExtension.php new file mode 100644 index 000000000..f83efa82b --- /dev/null +++ b/src/Test/Behat/src/FoundryExtension.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat; + +use Behat\Behat\EventDispatcher\ServiceContainer\EventDispatcherExtension; +use Behat\Testwork\Call\ServiceContainer\CallExtension; +use Behat\Testwork\ServiceContainer\Extension; +use Behat\Testwork\ServiceContainer\ExtensionManager; +use DAMA\DoctrineTestBundle\Behat\ServiceContainer\DoctrineExtension; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Zenstruck\Foundry\Test\Behat\Exception\DamaNativeExtensionIncompatibility; +use Zenstruck\Foundry\Test\Behat\Listener\BootConfigurationListener; +use Zenstruck\Foundry\Test\Behat\Listener\DatabaseResetListener; +use Zenstruck\Foundry\Test\Behat\Listener\LoadFixturesListener; +use Zenstruck\Foundry\Test\Behat\Listener\StepTranslationsListener; + +final class FoundryExtension implements Extension +{ + public function process(ContainerBuilder $container): void + { + } + + public function getConfigKey(): string + { + return 'zenstruck_foundry'; + } + + public function initialize(ExtensionManager $extensionManager): void + { + } + + public function configure(ArrayNodeDefinition $builder): void + { + $builder + ->children() + ->enumNode('database_reset_mode') + ->values(\array_map(static fn(DatabaseResetMode $mode) => $mode->value, DatabaseResetMode::cases())) + ->defaultValue(DatabaseResetMode::DISABLED->value) + ->end() + ->booleanNode('enable_dama_support') + ->defaultFalse() + ->end() + ->arrayNode('steps') + ->info('Override built-in step definitions: map a canonical pattern to your own wording (keep the same placeholders/capture groups).') + ->normalizeKeys(false) + ->scalarPrototype()->end() + ->end() + ->arrayNode('translations') + ->info('Paths to translation catalogues (xliff/yaml/php) overriding built-in step patterns.') + ->beforeNormalization() + ->ifString() + ->then(static fn(string $v): array => [$v]) + ->end() + ->scalarPrototype()->end() + ->end() + ->scalarNode('locale') + ->info('Locale the step overrides apply to. Matches the ".feature" language (defaults to "en"), not the CLI "--lang" option.') + ->defaultValue('en') + ->end() + ->end() + ->validate() + ->ifTrue(static fn(array $v): bool => $v['enable_dama_support'] && DatabaseResetMode::DISABLED->value === $v['database_reset_mode']) + ->thenInvalid('Foundry\'s DAMA support cannot be enabled when database reset is disabled.') + ->end(); + } + + public function load(ContainerBuilder $container, array $config): void + { + $container->register('.zenstruck_foundry.behat.listener.boot_configuration', BootConfigurationListener::class) + ->setArgument('$symfonyKernel', new Reference('fob_symfony.kernel')) + ->addTag(EventDispatcherExtension::SUBSCRIBER_TAG); + + $container->register('.zenstruck_foundry.behat.listener.load_fixture', LoadFixturesListener::class) + ->setArgument('$symfonyKernel', new Reference('fob_symfony.kernel')) + ->addTag(EventDispatcherExtension::SUBSCRIBER_TAG); + + $container->register(FoundryCallFilter::class) + ->setArgument('$symfonyKernel', new Reference('fob_symfony.kernel')) + ->addTag(CallExtension::CALL_FILTER_TAG); + + if ($config['steps'] || $config['translations']) { + $container->register('.zenstruck_foundry.behat.listener.step_translations', StepTranslationsListener::class) + ->setArgument('$translator', new Reference('translator')) + ->setArgument('$steps', $config['steps']) + ->setArgument('$translations', $config['translations']) + ->setArgument('$locale', $config['locale']) + ->addTag(EventDispatcherExtension::SUBSCRIBER_TAG); + } + + $databaseResetMode = DatabaseResetMode::from($config['database_reset_mode']); + + if (DatabaseResetMode::DISABLED === $databaseResetMode) { + return; + } + + if ($this->damaNativeExtensionIsEnabled($container)) { + if ($config['enable_dama_support']) { + throw DamaNativeExtensionIncompatibility::withFoundryDamaSupport(); + } + + if (DatabaseResetMode::FEATURE === $databaseResetMode) { + throw DamaNativeExtensionIncompatibility::withFeatureResetDbMode(); + } + + if (DatabaseResetMode::MANUAL === $databaseResetMode) { + throw DamaNativeExtensionIncompatibility::withManualResetDbMode(); + } + } + + $container->register('.zenstruck_foundry.behat.listener.database_reset', DatabaseResetListener::class) + ->setArgument('$symfonyKernel', new Reference('fob_symfony.kernel')) + ->setArgument('$resetMode', $databaseResetMode) + ->setArgument('$damaSupportEnabled', $config['enable_dama_support']) + ->setArgument('$damaNativeExtensionIsEnabled', $this->damaNativeExtensionIsEnabled($container)) + ->addTag(EventDispatcherExtension::SUBSCRIBER_TAG); + } + + private function damaNativeExtensionIsEnabled(ContainerBuilder $container): bool + { + return \in_array(DoctrineExtension::class, (array) $container->getParameter('extensions'), true); + } +} diff --git a/src/Test/Behat/src/FoundryTableNode.php b/src/Test/Behat/src/FoundryTableNode.php new file mode 100644 index 000000000..aedcfe85b --- /dev/null +++ b/src/Test/Behat/src/FoundryTableNode.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat; + +use Behat\Gherkin\Node\TableNode; + +use function Zenstruck\Foundry\set; + +/** + * @internal + * + * @method list> getHash() + * @method list> getColumnsHash() + * @method list getColumn(int $index) + * @method list> getRows() + * @method list getRow(int $index) + * @method array> getTable() + * @method array> getRowsHash() + * @method list getRowsHash() + */ +final class FoundryTableNode extends TableNode +{ + /** @var array */ + private array $maxLineLength = []; + + private FactoryShortNameResolver $factoryShortNameResolver; // @phpstan-ignore property.uninitialized + private ObjectRegistry $objectRegistry; // @phpstan-ignore property.uninitialized + + /** + * @param array $maxLineLength + * @param array> $table + */ + public static function create( + FactoryShortNameResolver $factoryShortNameResolver, + ObjectRegistry $objectRegistry, + array $maxLineLength, + array $table, + ): static { + // let's neutralize the table node constructor: it checks if the table contains only scalar values, + // but we want our TableNode to carry objects + + // This is super hacky but seems to work well + + // All other sanity checks performed by the constructor + // are not relevant, since they have already been performed previously + + $tableNode = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + set($tableNode, 'table', $table); + + $tableNode->factoryShortNameResolver = $factoryShortNameResolver; + $tableNode->objectRegistry = $objectRegistry; + $tableNode->maxLineLength = $maxLineLength; + + return $tableNode; + } + + public function getRowAsString($rowNum): string + { + $values = []; + foreach ($this->getRow($rowNum) as $column => $value) { + $values[] = $this->padRight(' '.$this->getValueAsString($value).' ', $this->maxLineLength[$column] + 2); + } + + return \sprintf('|%s|', \implode('|', $values)); + } + + public function getRowAsStringWithWrappedValues($rowNum, $wrapper): string + { + $values = []; + foreach ($this->getRow($rowNum) as $column => $value) { + $value = $this->padRight(' '.$this->getValueAsString($value).' ', $this->maxLineLength[$column] + 2); + + $values[] = \call_user_func($wrapper, $value, $column); + } + + return \sprintf('|%s|', \implode('|', $values)); + } + + private function getValueAsString(mixed $value): string + { + return match (true) { + !\is_object($value) => (string) $value, + $value instanceof \DateTimeInterface => $value->format('Y-m-d H:i:s'), + $value instanceof \BackedEnum => (string) $value->value, + $this->objectRegistry->isStored($value) => "{$this->factoryShortNameResolver->getShortNameForClass($value::class)} {$this->objectRegistry->getNameFor($value)}", + default => throw new \LogicException('Unsupported value type: '.\get_debug_type($value)), + }; + } +} diff --git a/src/Test/Behat/src/Listener/BootConfigurationListener.php b/src/Test/Behat/src/Listener/BootConfigurationListener.php new file mode 100644 index 000000000..20876176f --- /dev/null +++ b/src/Test/Behat/src/Listener/BootConfigurationListener.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Listener; + +use Behat\Behat\EventDispatcher\Event\ExampleTested; +use Behat\Behat\EventDispatcher\Event\FeatureTested; +use Behat\Behat\EventDispatcher\Event\ScenarioTested; +use Behat\Testwork\EventDispatcher\Event\ExerciseCompleted; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\KernelInterface; +use Zenstruck\Foundry\Configuration; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class BootConfigurationListener implements EventSubscriberInterface +{ + public function __construct( + private readonly KernelInterface $symfonyKernel, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + ExerciseCompleted::BEFORE => ['bootFoundry', 100], + ExerciseCompleted::AFTER => ['shutdownFoundry', -100], + + FeatureTested::BEFORE => ['bootFoundry', 100], + FeatureTested::AFTER => ['shutdownFoundryAfterFeature', -100], + + ScenarioTested::BEFORE => ['bootFoundry', 100], + ExampleTested::BEFORE => ['bootFoundry', 100], + ]; + } + + public function bootFoundry(): void + { + if (Configuration::isBooted()) { + return; + } + + Configuration::boot( + fn() => $this->symfonyKernel->getContainer()->get('.zenstruck_foundry.configuration') // @phpstan-ignore argument.type + ); + } + + public function shutdownFoundry(): void + { + Configuration::shutdown(); + } + + /** + * In any case, we want to shutdown Foundry after each feature: + * - to reset the object registry + * - to reset the story registry + */ + public function shutdownFoundryAfterFeature(): void + { + $this->objectRegistry()->reset(); + Configuration::shutdown(); + } + + private function objectRegistry(): ObjectRegistry + { + return $this->symfonyKernel->getContainer()->get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore return.type + } +} diff --git a/src/Test/Behat/src/Listener/DatabaseResetListener.php b/src/Test/Behat/src/Listener/DatabaseResetListener.php new file mode 100644 index 000000000..79d1b9cb5 --- /dev/null +++ b/src/Test/Behat/src/Listener/DatabaseResetListener.php @@ -0,0 +1,216 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Listener; + +use Behat\Behat\EventDispatcher\Event\BeforeFeatureTested; +use Behat\Behat\EventDispatcher\Event\BeforeScenarioTested; +use Behat\Behat\EventDispatcher\Event\ExampleTested; +use Behat\Behat\EventDispatcher\Event\FeatureTested; +use Behat\Behat\EventDispatcher\Event\ScenarioTested; +use Behat\Gherkin\Node\TaggedNodeInterface; +use Behat\Testwork\EventDispatcher\Event\ExerciseCompleted; +use DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\KernelInterface; +use Zenstruck\Foundry\Configuration; +use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager; +use Zenstruck\Foundry\StoryRegistry; +use Zenstruck\Foundry\Test\Behat\DatabaseResetMode; +use Zenstruck\Foundry\Test\Behat\Exception\DamaNativeExtensionIncompatibility; +use Zenstruck\Foundry\Test\Behat\Exception\InvalidResetDbTag; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class DatabaseResetListener implements EventSubscriberInterface +{ + private const RESET_DB_TAG = 'resetDB'; + private const NO_RESET_DB_TAG = 'noResetDB'; + + public function __construct( + private readonly KernelInterface $symfonyKernel, + private readonly DatabaseResetMode $resetMode, + private readonly bool $damaSupportEnabled, + private readonly bool $damaNativeExtensionIsEnabled, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + ExerciseCompleted::BEFORE => ['resetBeforeSuite', -10], // -10 because it should occur after Dama + ExerciseCompleted::AFTER => 'disableStaticConnection', + + FeatureTested::BEFORE => [ + ['validateFeature', 10], + ['resetDatabaseIfNeeded'], + ], + ScenarioTested::BEFORE => [ + ['validateScenario', 10], + ['resetDatabaseIfNeeded'], + ], + ExampleTested::BEFORE => [ + ['validateScenario', 10], + ['resetDatabaseIfNeeded'], + ], + + // a shutdown is needed after each scenario to ensure StoriesRegistry is reset + ScenarioTested::AFTER => 'shutdownFoundryAfterScenario', + ExampleTested::AFTER => 'shutdownFoundryAfterScenario', + ]; + } + + public function resetBeforeSuite(): void + { + if ($this->damaSupportEnabled) { + StaticDriver::setKeepStaticConnections(true); + } + + if (DatabaseResetMode::MANUAL === $this->resetMode) { + return; + } + + ResetDatabaseManager::resetBeforeFirstTest($this->symfonyKernel); + } + + public function disableStaticConnection(): void + { + if ($this->damaSupportEnabled) { + StaticDriver::setKeepStaticConnections(false); + } + } + + public function validateFeature(BeforeFeatureTested $event): void + { + if ($this->hasResetDbTag($event) && DatabaseResetMode::FEATURE === $this->resetMode) { + throw InvalidResetDbTag::resetDbOnFeatureWithFeatureMode($event); + } + } + + public function validateScenario(BeforeScenarioTested $event): void + { + if ($this->hasResetDbTag($event) && DatabaseResetMode::SCENARIO === $this->resetMode) { + throw InvalidResetDbTag::resetDbOnScenarioWithScenarioMode($event); + } + + if ($this->hasResetDbTag($event) && $this->hasNoResetDbTag($event)) { + throw InvalidResetDbTag::bothTagsUsed($event); + } + } + + public function resetDatabaseIfNeeded(BeforeFeatureTested|BeforeScenarioTested $event): void + { + if (!$this->shouldResetDB($event)) { + return; + } + + $this->resetObjectRegistry(); + + // when the DB is reset, any stories should be able to reload + StoryRegistry::reset(); + + if (!ResetDatabaseManager::databaseHasBeenResetBeforeFirstTest()) { + ResetDatabaseManager::resetBeforeFirstTest($this->symfonyKernel); + } + + if ($this->damaSupportEnabled) { + StaticDriver::rollBack(); + StaticDriver::beginTransaction(); + + return; + } + + ResetDatabaseManager::resetBeforeEachTest($this->symfonyKernel); + } + + public function shutdownFoundryAfterScenario(): void + { + if (DatabaseResetMode::SCENARIO !== $this->resetMode) { + return; + } + + $this->resetObjectRegistry(); + Configuration::shutdown(); + } + + private function shouldResetDB(BeforeFeatureTested|BeforeScenarioTested $event): bool + { + if ($this->hasNoResetDbTag($event)) { + return false; + } + + if ($this->hasResetDbTag($event)) { + return true; + } + + return $event instanceof BeforeScenarioTested && DatabaseResetMode::SCENARIO === $this->resetMode + || $event instanceof BeforeFeatureTested && DatabaseResetMode::FEATURE === $this->resetMode; + } + + private function hasResetDbTag(BeforeFeatureTested|BeforeScenarioTested $event): bool + { + $node = $event instanceof BeforeFeatureTested ? $event->getFeature() : $event->getScenario(); + + if (!$node instanceof TaggedNodeInterface) { + return false; + } + + $hasResetDbTag = $node->hasTag(self::RESET_DB_TAG); + + if (!$hasResetDbTag) { + return false; + } + + if (DatabaseResetMode::SCENARIO === $this->resetMode) { + throw InvalidResetDbTag::resetDbWithScenarioMode($event); + } + + return true; + } + + private function hasNoResetDbTag(BeforeFeatureTested|BeforeScenarioTested $event): bool + { + $node = $event instanceof BeforeFeatureTested ? $event->getFeature() : $event->getScenario(); + + if (!$node instanceof TaggedNodeInterface) { + return false; + } + + $hasNoResetDbTag = $node->hasTag(self::NO_RESET_DB_TAG); + + if (!$hasNoResetDbTag) { + return false; + } + + if ($this->damaNativeExtensionIsEnabled) { + throw DamaNativeExtensionIncompatibility::withNoResetDbTag(); + } + + return match ($this->resetMode) { + DatabaseResetMode::MANUAL => throw InvalidResetDbTag::noResetDbWithManualMode($event), + DatabaseResetMode::FEATURE => throw InvalidResetDbTag::noResetDbWithFeatureMode($event), + default => true, + }; + } + + private function resetObjectRegistry(): void + { + $this->objectRegistry()->reset(); + } + + private function objectRegistry(): ObjectRegistry + { + return $this->symfonyKernel->getContainer()->get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore return.type + } +} diff --git a/src/Test/Behat/src/Listener/LoadFixturesListener.php b/src/Test/Behat/src/Listener/LoadFixturesListener.php new file mode 100644 index 000000000..0209beec7 --- /dev/null +++ b/src/Test/Behat/src/Listener/LoadFixturesListener.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Listener; + +use Behat\Behat\EventDispatcher\Event\AfterScenarioSetup; +use Behat\Behat\EventDispatcher\Event\ExampleTested; +use Behat\Behat\EventDispatcher\Event\ScenarioTested; +use Behat\Gherkin\Node\TaggedNodeInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\KernelInterface; +use Zenstruck\Foundry\Story\FixtureStoryResolver; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class LoadFixturesListener implements EventSubscriberInterface +{ + private const FIXTURE_TAG_PATTERN = '/^withFixture\(([^)]+)\)$/'; + + public function __construct( + private readonly KernelInterface $symfonyKernel, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + ScenarioTested::AFTER_SETUP => 'loadFixtureIfTagged', + ExampleTested::AFTER_SETUP => 'loadFixtureIfTagged', + ]; + } + + public function loadFixtureIfTagged(AfterScenarioSetup $event): void + { + $scenario = $event->getScenario(); + $feature = $event->getFeature(); + + $tags = []; + + $tags = [...$tags, ...$feature->getTags()]; + + if ($scenario instanceof TaggedNodeInterface) { + $tags = [...$tags, ...$scenario->getTags()]; + } + + if (!$tags) { + return; + } + + $fixtureNames = $this->parseFixtureName($tags); + + if ([] === $fixtureNames) { + return; + } + + foreach ($fixtureNames as $fixtureName) { + $stories = $this->fixtureStoryResolver()->resolve($fixtureName); + + foreach ($stories as $storyClass) { + $storyClass::load(); + } + } + } + + /** + * @param list $tags + * @return list + */ + private function parseFixtureName(array $tags): array + { + $fixtureNames = []; + + foreach ($tags as $tag) { + if (\preg_match(self::FIXTURE_TAG_PATTERN, $tag, $matches)) { + $fixtureNames[] = $matches[1]; + } + } + + return $fixtureNames; + } + + private function fixtureStoryResolver(): FixtureStoryResolver + { + return $this->symfonyKernel->getContainer()->get('.zenstruck_foundry.story.fixture_resolver'); // @phpstan-ignore return.type + } +} diff --git a/src/Test/Behat/src/Listener/StepTranslationsListener.php b/src/Test/Behat/src/Listener/StepTranslationsListener.php new file mode 100644 index 000000000..c7941b7aa --- /dev/null +++ b/src/Test/Behat/src/Listener/StepTranslationsListener.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Listener; + +use Behat\Testwork\EventDispatcher\Event\BeforeSuiteTested; +use Behat\Testwork\EventDispatcher\Event\SuiteTested; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Translation\Translator; +use Zenstruck\Foundry\Test\Behat\Exception\UnsupportedTranslationResource; + +/** + * Overrides the built-in step definition patterns by registering translations + * for the canonical patterns onto Behat's translator. + * + * @internal + * @author Nicolas PHILIPPE + */ +final class StepTranslationsListener implements EventSubscriberInterface +{ + /** + * @param array $steps canonical pattern => overriding pattern + * @param list $translations paths to xliff/yaml/php catalogues + */ + public function __construct( + private readonly Translator $translator, + private readonly array $steps, + private readonly array $translations, + private readonly string $locale, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + SuiteTested::BEFORE => ['registerTranslations', 200], + ]; + } + + public function registerTranslations(BeforeSuiteTested $event): void + { + $suiteName = $event->getSuite()->getName(); + + if ($this->steps) { + $this->translator->addResource('array', $this->steps, $this->locale, $suiteName); + } + + foreach ($this->translations as $path) { + $this->translator->addResource(self::loaderFor($path), $path, $this->locale, $suiteName); + } + } + + private static function loaderFor(string $path): string + { + return match (\mb_strtolower(\pathinfo($path, \PATHINFO_EXTENSION))) { + 'yaml', 'yml' => 'yaml', + 'xliff', 'xlf' => 'xliff', + 'php' => 'php', + default => throw UnsupportedTranslationResource::forPath($path), + }; + } +} diff --git a/src/Test/Behat/src/ObjectRegistry.php b/src/Test/Behat/src/ObjectRegistry.php new file mode 100644 index 000000000..41daff993 --- /dev/null +++ b/src/Test/Behat/src/ObjectRegistry.php @@ -0,0 +1,177 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat; + +use Symfony\Component\Uid\AbstractUid; +use Zenstruck\Foundry\Persistence\Event\AfterPersist; +use Zenstruck\Foundry\Persistence\PersistenceManager; +use Zenstruck\Foundry\Story\Event\StateAddedToStory; +use Zenstruck\Foundry\Test\Behat\Exception\ObjectAlreadyRegistered; +use Zenstruck\Foundry\Test\Behat\Exception\ObjectNotFound; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class ObjectRegistry +{ + /** + * We need to use static properties in order that this is kept between kernel resets. + */ + + /** @var array> */ + private static array $objects = []; + + /** @var array */ + private static array $lastId = []; + + public function __construct( + private readonly FactoryShortNameResolver $factoryShortNameResolver, + private readonly PersistenceManager $persistenceManager, + ) { + } + + /** + * @param StateAddedToStory $event + */ + public function storeAfterStateAddedToStory(StateAddedToStory $event): void + { + $this->store($event->object, $event->name); + } + + public function store(object $object, string $objectName): void + { + if ($this->has($object::class, $objectName)) { + throw ObjectAlreadyRegistered::forClassAndName($object::class, $objectName); + } + + self::$objects[$object::class][$objectName] = $object; + } + + /** + * @param class-string $objectClass + */ + public function has(string $objectClass, string $objectName): bool + { + return isset(self::$objects[$objectClass][$objectName]); + } + + /** + * @param AfterPersist $event + */ + public function storeLastId(AfterPersist $event): void + { + self::$lastId = $this->persistenceManager->getIdentifierValues($event->object); + } + + public function getByFactoryShortName(string $factoryShortName, string $objectName): object + { + $objectClass = $this->factoryShortNameResolver->targetObjectClassFor($factoryShortName); + + if (!$this->has($objectClass, $objectName)) { + throw ObjectNotFound::forFactoryAndName($factoryShortName, $objectName); + } + + return self::$objects[$objectClass][$objectName]; + } + + /** + * @param class-string $objectClass + * + * @throws ObjectNotFound + */ + public function getByObjectClass(string $objectClass, string $objectName): object + { + if (!$this->has($objectClass, $objectName)) { + throw ObjectNotFound::forClassAndName($objectClass, $objectName); + } + + return self::$objects[$objectClass][$objectName]; + } + + public function reset(): void + { + self::$objects = []; + self::$lastId = []; + } + + public function lastId(): int|string + { + if (!self::$lastId) { + throw new \RuntimeException('No last id found.'); + } + + return $this->coerceIdToScalar(self::$lastId); + } + + public function lastIdFor(string $factoryShortName): int|string + { + $objects = self::$objects[$this->factoryShortNameResolver->targetObjectClassFor($factoryShortName)] ?? []; + + if (0 === \count($objects)) { + throw new \InvalidArgumentException("No object of type \"{$factoryShortName}\" found."); + } + + return $this->coerceIdToScalar( + $this->persistenceManager->getIdentifierValues( + array_last($objects) + ) + ); + } + + public function idFor(string $factoryShortName, string $objectName): int|string + { + $object = $this->getByFactoryShortName($factoryShortName, $objectName); + + return $this->coerceIdToScalar( + $this->persistenceManager->getIdentifierValues($object) + ); + } + + public function isStored(object $object): bool + { + return array_any( + self::$objects[$object::class] ?? [], + static fn(object $o) => $o === $object + ); + } + + public function getNameFor(object $object): string + { + return array_find_key( + self::$objects[$object::class] ?? [], + static fn(object $o) => $o === $object + ) ?? throw new \LogicException('Object is not stored in the registry.'); + } + + /** + * @param array $ids + */ + private function coerceIdToScalar(array $ids): int|string + { + if (1 !== \count($ids)) { + throw new \InvalidArgumentException('Cannot get last id: generic entity must have exactly one identifier.'); + } + + $id = array_first($ids); + + if ($id instanceof AbstractUid) { + return $id->toRfc4122(); + } + + if (!\is_int($id) && !\is_string($id)) { + throw new \InvalidArgumentException(\sprintf('Wrong type for the id: expected int, string or Uid, got "%s".', \get_debug_type($id))); + } + + return $id; + } +} diff --git a/src/Test/Behat/symfony.lock b/src/Test/Behat/symfony.lock new file mode 100644 index 000000000..476395496 --- /dev/null +++ b/src/Test/Behat/symfony.lock @@ -0,0 +1,163 @@ +{ + "dama/doctrine-test-bundle": { + "version": "8.6", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "8.3", + "ref": "dfc51177476fb39d014ed89944cde53dc3326d23" + } + }, + "doctrine/deprecations": { + "version": "1.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "87424683adc81d7dc305eefec1fced883084aab9" + } + }, + "doctrine/doctrine-bundle": { + "version": "3.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.0", + "ref": "18ee08e513ba0303fd09a01fc1c934870af06ffa" + }, + "files": [ + "config/packages/doctrine.yaml", + "src/Entity/.gitignore", + "src/Repository/.gitignore" + ] + }, + "friends-of-behat/symfony-extension": { + "version": "2.6", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "2.0", + "ref": "1e012e04f573524ca83795cd19df9ea690adb604" + } + }, + "phpunit/phpunit": { + "version": "12.5", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "11.1", + "ref": "1117deb12541f35793eec9fff7494d7aa12283fc" + }, + "files": [ + ".env.test", + "phpunit.dist.xml", + "tests/bootstrap.php", + "bin/phpunit" + ] + }, + "symfony/console": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.3", + "ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461" + }, + "files": [ + "bin/console" + ] + }, + "symfony/flex": { + "version": "2.10", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.4", + "ref": "52e9754527a15e2b79d9a610f98185a1fe46622a" + }, + "files": [ + ".env", + ".env.dev" + ] + }, + "symfony/framework-bundle": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.4", + "ref": "09f6e081c763a206802674ce0cb34a022f0ffc6d" + }, + "files": [ + "config/packages/cache.yaml", + "config/packages/framework.yaml", + "config/preload.php", + "config/routes/framework.yaml", + "config/services.yaml", + "public/index.php", + "src/Controller/.gitignore", + "src/Kernel.php", + ".editorconfig" + ] + }, + "symfony/property-info": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.3", + "ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7" + }, + "files": [ + "config/packages/property_info.yaml" + ] + }, + "symfony/routing": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.4", + "ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded" + }, + "files": [ + "config/packages/routing.yaml", + "config/routes.yaml" + ] + }, + "symfony/translation": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.3", + "ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843" + }, + "files": [ + "config/packages/translation.yaml", + "translations/.gitignore" + ] + }, + "symfony/uid": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.0", + "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" + } + }, + "zenstruck/foundry": { + "version": "2.9", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.7", + "ref": "7fc98f546dfeaa83cc2110634f8ff078d070b965" + }, + "files": [ + "config/packages/zenstruck_foundry.yaml", + "src/Story/AppStory.php" + ] + } +} diff --git a/src/Test/Behat/symlink-vendor.sh b/src/Test/Behat/symlink-vendor.sh new file mode 100755 index 000000000..cab8c58a2 --- /dev/null +++ b/src/Test/Behat/symlink-vendor.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_SRC="$(cd "${SCRIPT_DIR}/../../.." && pwd)/src" +VENDOR_SRC="${SCRIPT_DIR}/vendor/zenstruck/foundry/src" + +if [ ! -d "${VENDOR_SRC}" ]; then + echo "Directory vendor/zenstruck/foundry/src does not exist yet." + exit 1 +fi + +for item in "${ROOT_SRC}"/*; do + name=$(basename "$item") + + if [ "$name" = "Test" ]; then + continue + fi + + target="${VENDOR_SRC}/${name}" + + rm -rf "$target" + + ln -s "$item" "$target" +done + +echo -e "\nSymlinks created in ${VENDOR_SRC}\n" diff --git a/src/Test/Behat/tests/Fixture/BehatTestKernel.php b/src/Test/Behat/tests/Fixture/BehatTestKernel.php new file mode 100644 index 000000000..c7e3b357e --- /dev/null +++ b/src/Test/Behat/tests/Fixture/BehatTestKernel.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture; + +use FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; +use Zenstruck\Foundry\Tests\Fixture\App\Controller\HelloWorldController; +use Zenstruck\Foundry\Tests\Fixture\App\Controller\UpdateGenericModel; +use Zenstruck\Foundry\Tests\Fixture\FoundryTestKernel; + +class BehatTestKernel extends FoundryTestKernel +{ + public function registerBundles(): iterable + { + yield from parent::registerBundles(); + + yield new FriendsOfBehatSymfonyExtensionBundle(); + } + + protected function configureContainer(ContainerConfigurator $configurator, LoaderInterface $loader, ContainerBuilder $c): void + { + parent::configureContainer($configurator, $loader, $c); + + $c->loadFromExtension('zenstruck_foundry', [ + 'persistence' => ['flush_once' => true], + 'enable_auto_refresh_with_lazy_objects' => self::usePHP84LazyObjects(), + 'orm' => [ + 'reset' => [ + 'mode' => ResetDatabaseMode::SCHEMA, + ], + ], + ]); + + $c->register(HelloWorldController::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); + $c->register(UpdateGenericModel::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); + $c->register(ResetDisabledTestContext::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(TestFoundryContext::class)->setAutowired(true)->setAutoconfigured(true); + + $configurator->services() + ->load('Zenstruck\\Foundry\\Test\\Behat\\Tests\\Fixture\\Factories\\', __DIR__.'/Factories') + ->autowire() + ->autoconfigure(); + + $configurator->services() + ->load('Zenstruck\\Foundry\\Tests\\Fixture\\Factories\\', __DIR__.'/../../../../../tests/Fixture/Factories') + ->autowire() + ->autoconfigure(); + + $configurator->services() + ->load('Zenstruck\\Foundry\\Test\\Behat\\Tests\\Fixture\\Stories\\', __DIR__.'/Stories') + ->autowire() + ->autoconfigure(); + } + + protected function baseFixturePath(): string + { + return '%kernel.project_dir%/../../../tests/Fixture'; + } +} diff --git a/src/Test/Behat/tests/Fixture/Factories/EntityWithUidFactory.php b/src/Test/Behat/tests/Fixture/Factories/EntityWithUidFactory.php new file mode 100644 index 000000000..56365c322 --- /dev/null +++ b/src/Test/Behat/tests/Fixture/Factories/EntityWithUidFactory.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Factories; + +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Test\Behat\Attribute\FactoryShortName; +use Zenstruck\Foundry\Tests\Fixture\Entity\EntityWithUid; + +/** + * @extends PersistentObjectFactory + */ +#[FactoryShortName(shortName: 'entity with uid', pluralName: 'entities with uid')] +final class EntityWithUidFactory extends PersistentObjectFactory +{ + public static function class(): string + { + return EntityWithUid::class; + } + + protected function defaults(): array + { + return []; + } +} diff --git a/src/Test/Behat/tests/Fixture/Factories/Tag/TagFactory.php b/src/Test/Behat/tests/Fixture/Factories/Tag/TagFactory.php new file mode 100644 index 000000000..f9dff4462 --- /dev/null +++ b/src/Test/Behat/tests/Fixture/Factories/Tag/TagFactory.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Factories\Tag; + +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Test\Behat\Attribute\FactoryShortName; +use Zenstruck\Foundry\Tests\Fixture\Entity\Tag; + +/** + * @extends PersistentObjectFactory + */ +#[FactoryShortName('tag2')] +final class TagFactory extends PersistentObjectFactory +{ + public static function class(): string + { + return Tag::class; + } + + protected function defaults(): array + { + return [ + 'name' => self::faker()->word(), + ]; + } +} diff --git a/src/Test/Behat/tests/Fixture/Factories/TagFactory.php b/src/Test/Behat/tests/Fixture/Factories/TagFactory.php new file mode 100644 index 000000000..2541bad2b --- /dev/null +++ b/src/Test/Behat/tests/Fixture/Factories/TagFactory.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Factories; + +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Tests\Fixture\Entity\Tag; + +/** + * @extends PersistentObjectFactory + */ +final class TagFactory extends PersistentObjectFactory +{ + public static function class(): string + { + return Tag::class; + } + + protected function defaults(): array + { + return [ + 'name' => self::faker()->word(), + ]; + } +} diff --git a/src/Test/Behat/tests/Fixture/ResetDisabledTestContext.php b/src/Test/Behat/tests/Fixture/ResetDisabledTestContext.php new file mode 100644 index 000000000..9f4588a06 --- /dev/null +++ b/src/Test/Behat/tests/Fixture/ResetDisabledTestContext.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture; + +use Behat\Behat\Context\Context; +use Behat\Hook\BeforeScenario; +use Symfony\Component\HttpKernel\KernelInterface; +use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager; + +/** + * Context for testing disabled mode - resets DB only before the first feature. + */ +final class ResetDisabledTestContext implements Context +{ + public function __construct( + private readonly KernelInterface $kernel, + ) { + } + + #[BeforeScenario] + public function resetDBOnce(): void + { + ResetDatabaseManager::resetBeforeFirstTest($this->kernel); + } +} diff --git a/src/Test/Behat/tests/Fixture/Stories/CategoryStory.php b/src/Test/Behat/tests/Fixture/Stories/CategoryStory.php new file mode 100644 index 000000000..f87f3e4f3 --- /dev/null +++ b/src/Test/Behat/tests/Fixture/Stories/CategoryStory.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Stories; + +use Zenstruck\Foundry\Attribute\AsFixture; +use Zenstruck\Foundry\Story; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Category\CategoryFactory; + +#[AsFixture(name: 'behat-category', groups: ['behat-stories'])] +final class CategoryStory extends Story +{ + public function build(): void + { + $this->addState( + 'category fixture', + CategoryFactory::createOne() + ); + } +} diff --git a/src/Test/Behat/tests/Fixture/Stories/ConflictStory1.php b/src/Test/Behat/tests/Fixture/Stories/ConflictStory1.php new file mode 100644 index 000000000..f82a74708 --- /dev/null +++ b/src/Test/Behat/tests/Fixture/Stories/ConflictStory1.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Stories; + +use Zenstruck\Foundry\Attribute\AsFixture; +use Zenstruck\Foundry\Story; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Contact\ContactFactory; + +#[AsFixture(name: 'conflict-story-1', groups: ['conflict-test'])] +final class ConflictStory1 extends Story +{ + public function build(): void + { + $this->addState( + 'duplicate', + ContactFactory::createOne(['name' => 'From Story 1']) + ); + } +} diff --git a/src/Test/Behat/tests/Fixture/Stories/ConflictStory2.php b/src/Test/Behat/tests/Fixture/Stories/ConflictStory2.php new file mode 100644 index 000000000..c8ca91b34 --- /dev/null +++ b/src/Test/Behat/tests/Fixture/Stories/ConflictStory2.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Stories; + +use Zenstruck\Foundry\Attribute\AsFixture; +use Zenstruck\Foundry\Story; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Contact\ContactFactory; + +#[AsFixture(name: 'conflict-story-2', groups: ['conflict-test'])] +final class ConflictStory2 extends Story +{ + public function build(): void + { + $this->addState( + 'duplicate', + ContactFactory::createOne(['name' => 'From Story 2']) + ); + } +} diff --git a/src/Test/Behat/tests/Fixture/Stories/ContactStory.php b/src/Test/Behat/tests/Fixture/Stories/ContactStory.php new file mode 100644 index 000000000..78202c55d --- /dev/null +++ b/src/Test/Behat/tests/Fixture/Stories/ContactStory.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Stories; + +use Zenstruck\Foundry\Attribute\AsFixture; +use Zenstruck\Foundry\Story; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Contact\ContactFactory; + +#[AsFixture(name: 'behat-contacts', groups: ['behat-stories'])] +final class ContactStory extends Story +{ + public function build(): void + { + $this->addState( + 'john-doe', + ContactFactory::createOne(['name' => 'John Doe']) + ); + } +} diff --git a/src/Test/Behat/tests/Fixture/TestFoundryContext.php b/src/Test/Behat/tests/Fixture/TestFoundryContext.php new file mode 100644 index 000000000..bdacb1d54 --- /dev/null +++ b/src/Test/Behat/tests/Fixture/TestFoundryContext.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture; + +use Behat\Behat\Context\Context; +use Yceruto\BehatExtension\Context\ExceptionAssertionTrait; + +/** + * Provides the exception assertion steps alongside the built-in FoundryContext. + */ +final class TestFoundryContext implements Context +{ + use ExceptionAssertionTrait; +} diff --git a/src/Test/Behat/tests/Fixture/translations/foundry-steps.xliff b/src/Test/Behat/tests/Fixture/translations/foundry-steps.xliff new file mode 100644 index 000000000..7c70e85ed --- /dev/null +++ b/src/Test/Behat/tests/Fixture/translations/foundry-steps.xliff @@ -0,0 +1,11 @@ + + + + + + there is a(n) :factoryShortName named :objectName + create a :factoryShortName called :objectName + + + + diff --git a/src/Test/Behat/tests/Integration/Listener/BootConfigurationListenerTest.php b/src/Test/Behat/tests/Integration/Listener/BootConfigurationListenerTest.php new file mode 100644 index 000000000..3800603dc --- /dev/null +++ b/src/Test/Behat/tests/Integration/Listener/BootConfigurationListenerTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Integration\Behat\Listener; + +use PHPUnit\Framework\Attributes\Test; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Foundry\Configuration; +use Zenstruck\Foundry\Test\Behat\Listener\BootConfigurationListener; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\GenericEntityFactory; + +final class BootConfigurationListenerTest extends KernelTestCase +{ + #[Test] + public function it_boots_foundry_when_not_already_booted(): void + { + Configuration::shutdown(); + self::assertFalse(Configuration::isBooted()); + + $listener = $this->createListener(); + $listener->bootFoundry(); + + self::assertTrue(Configuration::isBooted()); + } + + #[Test] + public function it_shuts_down_foundry(): void + { + self::assertTrue(Configuration::isBooted()); + + $listener = $this->createListener(); + $listener->shutdownFoundry(); + + self::assertFalse(Configuration::isBooted()); + } + + #[Test] + public function it_shuts_down_foundry_after_feature_and_resets_registry(): void + { + $testObj = GenericEntityFactory::createOne(); + $registry = $this->objectRegistry(); + $registry->store($testObj, 'test-object'); + + self::assertTrue($registry->isStored($testObj)); + self::assertTrue(Configuration::isBooted()); + + $listener = $this->createListener(); + $listener->shutdownFoundryAfterFeature(); + + self::assertFalse($registry->isStored($testObj)); + self::assertFalse(Configuration::isBooted()); + } + + private function objectRegistry(): ObjectRegistry + { + return self::getContainer()->get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore return.type + } + + private function createListener(): BootConfigurationListener + { + return new BootConfigurationListener(self::$kernel ?? self::bootKernel()); + } +} diff --git a/src/Test/Behat/tests/Integration/Listener/DatabaseResetListenerTest.php b/src/Test/Behat/tests/Integration/Listener/DatabaseResetListenerTest.php new file mode 100644 index 000000000..98c23f9a3 --- /dev/null +++ b/src/Test/Behat/tests/Integration/Listener/DatabaseResetListenerTest.php @@ -0,0 +1,351 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Integration\Behat\Listener; + +use Behat\Behat\EventDispatcher\Event\BeforeFeatureTested; +use Behat\Behat\EventDispatcher\Event\BeforeScenarioTested; +use Behat\Gherkin\Node\FeatureNode; +use Behat\Gherkin\Node\ScenarioNode; +use Behat\Testwork\Environment\StaticEnvironment; +use Behat\Testwork\Suite\GenericSuite; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Foundry\Test\Behat\DatabaseResetMode; +use Zenstruck\Foundry\Test\Behat\Exception\DamaNativeExtensionIncompatibility; +use Zenstruck\Foundry\Test\Behat\Exception\InvalidResetDbTag; +use Zenstruck\Foundry\Test\Behat\Listener\DatabaseResetListener; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\GenericEntityFactory; + +final class DatabaseResetListenerTest extends KernelTestCase +{ + protected function setUp(): void + { + $this->objectRegistry()->reset(); + } + + /** + * @param list $tags + * @param class-string<\Throwable> $exceptionClass + */ + #[Test] + #[DataProvider('validateFeatureExceptionProvider')] + public function it_throws_exception_on_validate_feature( + DatabaseResetMode $mode, + array $tags, + string $exceptionClass, + string $exceptionMessage, + ): void { + $listener = $this->createListener($mode); + $event = $this->createFeatureEvent($tags); + + $this->expectException($exceptionClass); + $this->expectExceptionMessage($exceptionMessage); + + $listener->validateFeature($event); + } + + public static function validateFeatureExceptionProvider(): iterable + { + yield 'resetDB tag on feature with feature mode' => [ + DatabaseResetMode::FEATURE, + ['resetDB'], + InvalidResetDbTag::class, + 'Cannot use "@resetDB" tag on a feature with database_reset_mode set as "feature".', + ]; + } + + /** + * @param list $tags + * @param class-string<\Throwable> $exceptionClass + */ + #[Test] + #[DataProvider('validateScenarioExceptionProvider')] + public function it_throws_exception_on_validate_scenario( + DatabaseResetMode $mode, + array $tags, + string $exceptionClass, + string $exceptionMessage, + ): void { + $listener = $this->createListener($mode); + $event = $this->createScenarioEvent($tags); + + $this->expectException($exceptionClass); + $this->expectExceptionMessage($exceptionMessage); + + $listener->validateScenario($event); + } + + public static function validateScenarioExceptionProvider(): iterable + { + yield 'resetDB tag on scenario with scenario mode' => [ + DatabaseResetMode::SCENARIO, + ['resetDB'], + InvalidResetDbTag::class, + 'Cannot use "@resetDB" tag with database_reset_mode set as "scenario".', + ]; + + yield 'both resetDB and noResetDB tags on scenario' => [ + DatabaseResetMode::MANUAL, + ['resetDB', 'noResetDB'], + InvalidResetDbTag::class, + 'Cannot use "@noResetDB" tag with database_reset_mode set as "manual".', + ]; + } + + /** + * @param list $tags + */ + #[Test] + #[DataProvider('resetDatabaseIfNeededBehaviorProvider')] + public function it_resets_database_and_registries_when_needed( + DatabaseResetMode $mode, + string $eventType, + array $tags, + bool $shouldReset, + ): void { + $listener = $this->createListener($mode, damaSupportEnabled: true); + $objectRegistry = $this->objectRegistry(); + + $testObject = GenericEntityFactory::createOne(); + GenericEntityFactory::assert()->count(1); + + $objectRegistry->store($testObject, 'test-object'); + self::assertTrue($objectRegistry->isStored($testObject)); + + $event = 'feature' === $eventType ? $this->createFeatureEvent($tags) : $this->createScenarioEvent($tags); + + $listener->resetDatabaseIfNeeded($event); + + if ($shouldReset) { + self::assertFalse($objectRegistry->isStored($testObject)); + GenericEntityFactory::assert()->count(0); + } else { + self::assertTrue($objectRegistry->isStored($testObject)); + GenericEntityFactory::assert()->count(1); + } + } + + public static function resetDatabaseIfNeededBehaviorProvider(): iterable + { + yield 'scenario mode resets on scenario without tags' => [ + DatabaseResetMode::SCENARIO, + 'scenario', + [], + true, + ]; + + yield 'scenario mode does not reset with noResetDB tag' => [ + DatabaseResetMode::SCENARIO, + 'scenario', + ['noResetDB'], + false, + ]; + + yield 'feature mode resets on feature without tags' => [ + DatabaseResetMode::FEATURE, + 'feature', + [], + true, + ]; + + yield 'manual mode does not reset without resetDB tag' => [ + DatabaseResetMode::MANUAL, + 'scenario', + [], + false, + ]; + + yield 'manual mode resets with resetDB tag' => [ + DatabaseResetMode::MANUAL, + 'scenario', + ['resetDB'], + true, + ]; + + yield 'scenario mode does not reset on feature' => [ + DatabaseResetMode::SCENARIO, + 'feature', + [], + false, + ]; + + yield 'feature mode does not reset on scenario' => [ + DatabaseResetMode::FEATURE, + 'scenario', + [], + false, + ]; + } + + /** + * @param list $tags + * @param class-string<\Throwable> $exceptionClass + */ + #[Test] + #[DataProvider('resetDatabaseIfNeededExceptionProvider')] + public function it_throws_exception_on_reset_database_if_needed( + DatabaseResetMode $mode, + array $tags, + string $exceptionClass, + string $exceptionMessage, + bool $damaSupportEnabled = false, + bool $damaNativeExtensionIsEnabled = false, + ): void { + $listener = $this->createListener($mode, $damaSupportEnabled, $damaNativeExtensionIsEnabled); + $event = $this->createScenarioEvent($tags); + + $this->expectException($exceptionClass); + $this->expectExceptionMessage($exceptionMessage); + + $listener->resetDatabaseIfNeeded($event); + } + + public static function resetDatabaseIfNeededExceptionProvider(): iterable + { + yield 'noResetDB tag with dama native extension' => [ + DatabaseResetMode::SCENARIO, + ['noResetDB'], + DamaNativeExtensionIncompatibility::class, + 'Cannot use "@noResetDB" with native Behat extension for "dama/doctrine-test-bundle".', + false, + true, + ]; + + yield 'noResetDB tag with manual mode' => [ + DatabaseResetMode::MANUAL, + ['noResetDB'], + InvalidResetDbTag::class, + 'Cannot use "@noResetDB" tag with database_reset_mode set as "manual".', + ]; + + yield 'noResetDB tag with feature mode' => [ + DatabaseResetMode::FEATURE, + ['noResetDB'], + InvalidResetDbTag::class, + 'Cannot use "@noResetDB" with database_reset_mode set as "feature".', + ]; + } + + /** + * @param list $tags + */ + #[Test] + #[DataProvider('validateScenarioNoExceptionProvider')] + public function it_does_not_throw_on_validate_scenario( + DatabaseResetMode $mode, + array $tags, + ): void { + $this->expectNotToPerformAssertions(); + + $listener = $this->createListener($mode); + $event = $this->createScenarioEvent($tags); + + $listener->validateScenario($event); + } + + public static function validateScenarioNoExceptionProvider(): iterable + { + yield 'resetDB tag with manual mode' => [ + DatabaseResetMode::MANUAL, + ['resetDB'], + ]; + + yield 'no tags with scenario mode' => [ + DatabaseResetMode::SCENARIO, + [], + ]; + } + + #[Test] + public function it_does_not_throw_for_no_reset_d_b_tag_with_scenario_mode(): void + { + $this->expectNotToPerformAssertions(); + + $listener = $this->createListener(DatabaseResetMode::SCENARIO); + $event = $this->createScenarioEvent(['noResetDB']); + + $listener->resetDatabaseIfNeeded($event); + } + + #[Test] + public function it_validates_feature_without_reset_d_b_tag_in_feature_mode(): void + { + $this->expectNotToPerformAssertions(); + + $listener = $this->createListener(DatabaseResetMode::FEATURE); + $event = $this->createFeatureEvent([]); + + $listener->validateFeature($event); + } + + private function createListener( + DatabaseResetMode $mode, + bool $damaSupportEnabled = false, + bool $damaNativeExtensionIsEnabled = false, + ): DatabaseResetListener { + return new DatabaseResetListener(self::$kernel ?? self::bootKernel(), $mode, $damaSupportEnabled, $damaNativeExtensionIsEnabled); + } + + private function objectRegistry(): ObjectRegistry + { + return self::getContainer()->get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore return.type + } + + private function createEnvironment(): StaticEnvironment + { + return new StaticEnvironment(new GenericSuite('default', ['paths' => ['/path/to']])); + } + + /** + * @param list $tags + */ + private function createFeatureEvent(array $tags): BeforeFeatureTested + { + $feature = new FeatureNode( + 'Test Feature', + 'Description', + $tags, + null, + [], + 'feature', + 'en', + '/path/to/test.feature', + 1 + ); + + return new BeforeFeatureTested($this->createEnvironment(), $feature); + } + + /** + * @param list $tags + */ + private function createScenarioEvent(array $tags): BeforeScenarioTested + { + $scenario = new ScenarioNode('Test Scenario', $tags, [], 'scenario', 10); + + $feature = new FeatureNode( + 'Test Feature', + 'Description', + [], + null, + [$scenario], + 'feature', + 'en', + '/path/to/test.feature', + 1 + ); + + return new BeforeScenarioTested($this->createEnvironment(), $feature, $scenario); + } +} diff --git a/src/Test/Behat/tests/Integration/Listener/LoadFixturesListenerTest.php b/src/Test/Behat/tests/Integration/Listener/LoadFixturesListenerTest.php new file mode 100644 index 000000000..6076457bd --- /dev/null +++ b/src/Test/Behat/tests/Integration/Listener/LoadFixturesListenerTest.php @@ -0,0 +1,159 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Integration\Behat\Listener; + +use Behat\Behat\EventDispatcher\Event\AfterScenarioSetup; +use Behat\Gherkin\Node\FeatureNode; +use Behat\Gherkin\Node\OutlineNode; +use Behat\Gherkin\Node\ScenarioNode; +use Behat\Testwork\Environment\Environment; +use Behat\Testwork\Tester\Setup\Setup; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Foundry\Test\Behat\Exception\ObjectAlreadyRegistered; +use Zenstruck\Foundry\Test\Behat\Listener\LoadFixturesListener; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; +use Zenstruck\Foundry\Tests\Fixture\Entity\Category; +use Zenstruck\Foundry\Tests\Fixture\Entity\Contact; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Category\CategoryFactory; + +final class LoadFixturesListenerTest extends KernelTestCase +{ + protected function setUp(): void + { + $this->objectRegistry()->reset(); + } + + /** + * @param list $featureTags + * @param list $scenarioTags + */ + #[Test] + #[DataProvider('noFixtureLoadedProvider')] + public function it_does_not_load_fixtures(array $featureTags, array $scenarioTags): void + { + $listener = $this->createListener(); + $event = $this->createAfterScenarioSetupEvent($featureTags, $scenarioTags); + + $listener->loadFixtureIfTagged($event); + + CategoryFactory::assert()->count(0); + } + + public static function noFixtureLoadedProvider(): iterable + { + yield 'no tags' => [[], []]; + yield 'no fixture tags' => [['someTag', 'anotherTag'], []]; + yield 'invalid tag: tag without parentheses' => [[], ['withFixture']]; + yield 'invalid tag: tag with empty parentheses' => [[], ['withFixture()']]; + yield 'invalid tag: tag with wrong prefix' => [[], ['loadFixture(behat-category)']]; + yield 'invalid tag: tag with extra closing parenthesis' => [[], ['withFixture(behat-category))']]; + } + + /** + * @param list $featureTags + * @param list $scenarioTags + */ + #[Test] + #[DataProvider('singleCategoryFixtureLoadedProvider')] + public function it_loads_single_category_fixture(array $featureTags, array $scenarioTags, bool $isOutline = false): void + { + $listener = $this->createListener(); + $event = $isOutline + ? $this->createAfterScenarioSetupEventWithOutline($scenarioTags) + : $this->createAfterScenarioSetupEvent($featureTags, $scenarioTags); + + $listener->loadFixtureIfTagged($event); + + CategoryFactory::assert()->count(1); + $this->objectRegistry()->has(Category::class, 'category fixture'); + } + + public static function singleCategoryFixtureLoadedProvider(): iterable + { + yield 'from scenario tag' => [[], ['withFixture(behat-category)']]; + yield 'from feature tag' => [['withFixture(behat-category)'], []]; + yield 'outline example scenario' => [[], ['withFixture(behat-category)'], true]; + yield 'combined feature and scenario tags' => [['withFixture(behat-category)'], ['withFixture(behat-category)']]; + } + + #[Test] + public function it_loads_fixture_group(): void + { + $listener = $this->createListener(); + $event = $this->createAfterScenarioSetupEvent([], ['withFixture(behat-stories)']); + + $listener->loadFixtureIfTagged($event); + + CategoryFactory::assert()->count(2); + $this->objectRegistry()->has(Category::class, 'category fixture'); + $this->objectRegistry()->has(Contact::class, 'john-doe'); + } + + #[Test] + public function it_throws_if_states_name_conflict_in_stories(): void + { + $this->expectException(ObjectAlreadyRegistered::class); + $this->expectExceptionMessage('Object "duplicate" is already registered for class "'.Contact::class); + + $listener = $this->createListener(); + $event = $this->createAfterScenarioSetupEvent([], ['withFixture(conflict-test)']); + + $listener->loadFixtureIfTagged($event); + } + + private function createListener(): LoadFixturesListener + { + return new LoadFixturesListener(self::$kernel ?? self::bootKernel()); + } + + /** + * @param list $featureTags + * @param list $scenarioTags + */ + private function createAfterScenarioSetupEvent(array $featureTags, array $scenarioTags): AfterScenarioSetup + { + $scenario = new ScenarioNode('Test Scenario', $scenarioTags, [], 'scenario', 10); + + $feature = new FeatureNode( + 'Test Feature', 'Description', $featureTags, null, [$scenario], 'feature', 'en', '/path/to/test.feature', 1 + ); + + $environment = $this->createStub(Environment::class); + $setup = $this->createStub(Setup::class); + + return new AfterScenarioSetup($environment, $feature, $scenario, $setup); + } + + /** + * @param list $outlineTags + */ + private function createAfterScenarioSetupEventWithOutline(array $outlineTags): AfterScenarioSetup + { + $outline = new OutlineNode('Test Outline', $outlineTags, [], [], 'outline', 10); + + $feature = new FeatureNode( + 'Test Feature', 'Description', [], null, [$outline], 'feature', 'en', '/path/to/test.feature', 1 + ); + + $environment = $this->createStub(Environment::class); + $setup = $this->createStub(Setup::class); + + return new AfterScenarioSetup($environment, $feature, $outline, $setup); + } + + private function objectRegistry(): ObjectRegistry + { + return self::getContainer()->get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore return.type + } +} diff --git a/src/Test/Behat/tests/Unit/FactoryResolverTest.php b/src/Test/Behat/tests/Unit/FactoryResolverTest.php new file mode 100644 index 000000000..b84ff1c88 --- /dev/null +++ b/src/Test/Behat/tests/Unit/FactoryResolverTest.php @@ -0,0 +1,211 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Unit\Test\Behat; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Zenstruck\Foundry\ObjectFactory; +use Zenstruck\Foundry\Test\Behat\Attribute\FactoryShortName; +use Zenstruck\Foundry\Test\Behat\Exception\FactoryNotResolvable; +use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; + +final class FactoryResolverTest extends TestCase +{ + public static function factoriesWithConflictingShortNames(): iterable + { + yield 'same short name in attribute' => [[new Article1Factory(), new Article2Factory()]]; + yield 'same generated short name' => [[new Article1Factory(), new ArticleFactory()]]; + } + + #[Test] + public function it_resolves_factory_by_auto_generated_name(): void + { + $resolver = new FactoryShortNameResolver([$factory = new PostFactory()]); + + self::assertInstanceOf($factory::class, $resolver->factoryFor('post')); + self::assertInstanceOf($factory::class, $resolver->factoryFor('posts')); + } + + #[Test] + public function it_resolves_factory_case_insensitively(): void + { + $resolver = new FactoryShortNameResolver([$factory = new PostFactory()]); + + self::assertInstanceOf($factory::class, $resolver->factoryFor('Post')); + self::assertInstanceOf($factory::class, $resolver->factoryFor('POST')); + } + + #[Test] + public function it_resolves_factory_complex_short_name(): void + { + $resolver = new FactoryShortNameResolver([$factory = new BlogPostFactory()]); + + self::assertInstanceOf($factory::class, $resolver->factoryFor('blog post')); + self::assertInstanceOf($factory::class, $resolver->factoryFor('blog posts')); + + self::assertInstanceOf($factory::class, $resolver->factoryFor('BlOg PoSt')); + self::assertInstanceOf($factory::class, $resolver->factoryFor('BlOg PoSts')); + } + + #[Test] + public function it_uses_attribute_short_name(): void + { + $resolver = new FactoryShortNameResolver([$factory = new CustomNameFactory()]); + + self::assertInstanceOf($factory::class, $resolver->factoryFor('custom')); + self::assertInstanceOf($factory::class, $resolver->factoryFor('customs')); + } + + #[Test] + public function it_can_resolve_with_custom_plural_form(): void + { + $resolver = new FactoryShortNameResolver([$factory = new Article1Factory()]); + + self::assertInstanceOf($factory::class, $resolver->factoryFor('several articles')); + } + + #[Test] + public function it_throws_when_factory_not_found(): void + { + $resolver = new FactoryShortNameResolver([new PostFactory()]); + + $this->expectException(FactoryNotResolvable::class); + $this->expectExceptionMessage('Cannot resolve factory for name "unknown"'); + + $resolver->factoryFor('unknown'); + } + + #[Test] + #[DataProvider('factoriesWithConflictingShortNames')] + public function it_throws_on_conflict(array $factories): void + { + $resolver = new FactoryShortNameResolver($factories); + + $this->expectException(FactoryNotResolvable::class); + $this->expectExceptionMessage('Multiple factories found for name "article"'); + + $resolver->factoryFor('article'); + } + + #[Test] + public function it_checks_if_factory_exists_for_class(): void + { + $resolver = new FactoryShortNameResolver([new PostFactory()]); + + self::assertTrue($resolver->hasFactoryForClass(\stdClass::class)); + self::assertFalse($resolver->hasFactoryForClass(\DateTime::class)); + } + + #[Test] + public function it_gets_short_name_for_class(): void + { + $resolver = new FactoryShortNameResolver([new PostFactory()]); + + self::assertSame('post', $resolver->getShortNameForClass(\stdClass::class)); + } + + #[Test] + public function it_gets_short_name_for_class_with_custom_attribute(): void + { + $resolver = new FactoryShortNameResolver([new CustomNameFactory()]); + + self::assertSame('custom', $resolver->getShortNameForClass(\stdClass::class)); + } +} + +/** @extends ObjectFactory<\stdClass> */ +final class PostFactory extends ObjectFactory +{ + public static function class(): string + { + return \stdClass::class; + } + + protected function defaults(): array + { + return []; + } +} + +/** @extends ObjectFactory<\stdClass> */ +#[FactoryShortName('custom')] +final class CustomNameFactory extends ObjectFactory +{ + public static function class(): string + { + return \stdClass::class; + } + + protected function defaults(): array + { + return []; + } +} + +/** @extends ObjectFactory<\stdClass> */ +final class BlogPostFactory extends ObjectFactory +{ + public static function class(): string + { + return \stdClass::class; + } + + protected function defaults(): array + { + return []; + } +} + +/** @extends ObjectFactory<\stdClass> */ +final class ArticleFactory extends ObjectFactory +{ + public static function class(): string + { + return \stdClass::class; + } + + protected function defaults(): array + { + return []; + } +} + +/** @extends ObjectFactory<\stdClass> */ +#[FactoryShortName('article', 'several articles')] +final class Article1Factory extends ObjectFactory +{ + public static function class(): string + { + return \stdClass::class; + } + + protected function defaults(): array + { + return []; + } +} + +/** @extends ObjectFactory<\stdClass> */ +#[FactoryShortName('article')] +final class Article2Factory extends ObjectFactory +{ + public static function class(): string + { + return \stdClass::class; + } + + protected function defaults(): array + { + return []; + } +} diff --git a/src/Test/Behat/tests/Unit/FoundryCallFilterTest.php b/src/Test/Behat/tests/Unit/FoundryCallFilterTest.php new file mode 100644 index 000000000..7732222c7 --- /dev/null +++ b/src/Test/Behat/tests/Unit/FoundryCallFilterTest.php @@ -0,0 +1,502 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Unit\Test\Behat; + +use Behat\Behat\Definition\Call\DefinitionCall; +use Behat\Behat\Definition\Definition; +use Behat\Gherkin\Node\ExampleTableNode; +use Behat\Gherkin\Node\FeatureNode; +use Behat\Gherkin\Node\StepNode; +use Behat\Gherkin\Node\TableNode; +use Behat\Testwork\Call\Call; +use Behat\Testwork\Environment\Environment; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpKernel\KernelInterface; +use Zenstruck\Foundry\ObjectFactory; +use Zenstruck\Foundry\Persistence\PersistenceManager; +use Zenstruck\Foundry\Test\Behat\Attribute\FactoryShortName; +use Zenstruck\Foundry\Test\Behat\Exception\InvalidObjectParameter; +use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; +use Zenstruck\Foundry\Test\Behat\FoundryCallFilter; +use Zenstruck\Foundry\Test\Behat\FoundryContext; +use Zenstruck\Foundry\Test\Behat\FoundryTableNode; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; + +final class FoundryCallFilterTest extends TestCase +{ + private FoundryCallFilter $filter; + private FactoryShortNameResolver $factoryResolver; + private ObjectRegistry $objectRegistry; + + protected function setUp(): void + { + $this->factoryResolver = new FactoryShortNameResolver([ + new TestEntityFactory(), + new DatedEntityFactory(), + new EnumEntityFactory(), + new RelationEntityFactory(), + new ChildEntityFactory(), + ]); + $this->objectRegistry = new ObjectRegistry( + $this->factoryResolver, $this->createStub(PersistenceManager::class) + ); + $this->objectRegistry->reset(); + + $this->filter = $this->createFilterWithMockedKernel(); + } + + #[Test] + public function it_supports_call_with_table_node_argument(): void + { + $call = $this->createStub(Call::class); + $call->method('getArguments')->willReturn([new TableNode([['foo'], ['bar']])]); + + self::assertTrue($this->filter->supportsCall($call)); + } + + #[Test] + public function it_does_not_support_call_without_table_node(): void + { + $call = $this->createStub(Call::class); + $call->method('getArguments')->willReturn(['foo', 'bar']); + + self::assertFalse($this->filter->supportsCall($call)); + } + + #[Test] + public function it_does_not_support_call_with_example_table_node(): void + { + $call = $this->createStub(Call::class); + $call->method('getArguments')->willReturn([new ExampleTableNode([['foo'], ['bar']], 'example')]); + + self::assertFalse($this->filter->supportsCall($call)); + } + + #[Test] + public function it_returns_call_unchanged_when_not_definition_call(): void + { + $call = $this->createStub(Call::class); + $call->method('getArguments')->willReturn([new TableNode([['foo'], ['bar']])]); + + $result = $this->filter->filterCall($call); + + self::assertSame($call, $result); + } + + #[Test] + public function it_throws_when_no_factory_short_name_argument(): void + { + $call = $this->createDefinitionCallForFoundryContext( + ['table' => new TableNode([['property'], ['value']])], + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot filter call without a "$factoryShortName" argument.'); + + $this->filter->filterCall($call); + } + + #[Test] + #[DataProvider('tableNormalizationProvider')] + public function it_normalizes_table_values(array $input, array $expected): void + { + $table = $this->createTableNodeFromRow($input); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'test entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + self::assertInstanceOf(FoundryTableNode::class, $normalizedTable); + + $rows = $normalizedTable->getColumnsHash(); + self::assertCount(1, $rows); + self::assertEquals($expected, $rows[0]); + } + + public static function tableNormalizationProvider(): iterable + { + yield 'null value' => [ + ['property' => 'null'], + ['property' => null], + ]; + + yield 'true value' => [ + ['property' => 'true'], + ['property' => true], + ]; + + yield 'false value' => [ + ['property' => 'false'], + ['property' => false], + ]; + + yield 'string value kept as-is' => [ + ['property' => 'some text'], + ['property' => 'some text'], + ]; + + yield 'numeric string kept as-is for unknown property' => [ + ['property' => '42'], + ['property' => '42'], + ]; + + yield '_ref column is passed through' => [ + ['_ref' => 'my-ref'], + ['_ref' => 'my-ref'], + ]; + } + + #[Test] + public function it_normalizes_object_reference_with_explicit_factory(): void + { + $referencedObject = new TestEntity(id: 1, name: 'Referenced'); + $this->objectRegistry->store($referencedObject, 'the-ref'); + + $table = $this->createTableNodeFromRow(['relation' => '']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'test entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + $rows = $normalizedTable->getColumnsHash(); + + self::assertSame($referencedObject, $rows[0]['relation']); + } + + #[Test] + public function it_throws_on_invalid_object_reference(): void + { + $table = $this->createTableNodeFromRow(['relation' => '']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'test entity', + 'table' => $table, + ]); + + $this->expectException(InvalidObjectParameter::class); + $this->expectExceptionMessage('A reference to an object cannot be resolved in the table, at column "relation"'); + + $this->filter->filterCall($call); + } + + #[Test] + public function it_normalizes_date_value(): void + { + $table = $this->createTableNodeFromRow(['createdAt' => '2024-01-15 10:30:00']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'dated entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + $rows = $normalizedTable->getColumnsHash(); + + self::assertInstanceOf(\DateTimeImmutable::class, $rows[0]['createdAt']); + self::assertSame('2024-01-15 10:30:00', $rows[0]['createdAt']->format('Y-m-d H:i:s')); + } + + #[Test] + public function it_throws_on_invalid_date_value(): void + { + $table = $this->createTableNodeFromRow(['createdAt' => 'not-a-date']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'dated entity', + 'table' => $table, + ]); + + $this->expectException(InvalidObjectParameter::class); + $this->expectExceptionMessage('Invalid date given "not-a-date", at column "createdAt"'); + + $this->filter->filterCall($call); + } + + #[Test] + public function it_normalizes_string_enum_value(): void + { + $table = $this->createTableNodeFromRow(['status' => 'active']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'enum entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + $rows = $normalizedTable->getColumnsHash(); + + self::assertSame(TestStatus::ACTIVE, $rows[0]['status']); + } + + #[Test] + public function it_normalizes_int_enum_value(): void + { + $table = $this->createTableNodeFromRow(['priority' => '2']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'enum entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + $rows = $normalizedTable->getColumnsHash(); + + self::assertSame(TestPriority::MEDIUM, $rows[0]['priority']); + } + + #[Test] + public function it_throws_on_invalid_enum_value(): void + { + $table = $this->createTableNodeFromRow(['status' => 'invalid_status']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'enum entity', + 'table' => $table, + ]); + + $this->expectException(InvalidObjectParameter::class); + $this->expectExceptionMessage('Invalid enum value given "invalid_status", at column "status"'); + + $this->filter->filterCall($call); + } + + #[Test] + public function it_normalizes_object_by_type_inference(): void + { + $referencedEntity = new TestEntity(id: 1, name: 'Referenced'); + $this->objectRegistry->store($referencedEntity, 'the-relation'); + + $table = $this->createTableNodeFromRow(['relation' => 'the-relation']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'relation entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + $rows = $normalizedTable->getColumnsHash(); + + self::assertSame($referencedEntity, $rows[0]['relation']); + } + + #[Test] + public function it_handles_inherited_property_from_parent_class(): void + { + $table = $this->createTableNodeFromRow(['inheritedProperty' => 'value']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'child entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + $rows = $normalizedTable->getColumnsHash(); + + self::assertSame('value', $rows[0]['inheritedProperty']); + } + + /** + * @param array $row + */ + private function createTableNodeFromRow(array $row): TableNode + { + return new TableNode([ + \array_keys($row), + \array_values($row), + ]); + } + + private function createFilterWithMockedKernel(): FoundryCallFilter + { + $container = $this->createStub(ContainerInterface::class); + $container->method('get')->willReturnCallback(fn(string $id) => match ($id) { + '.zenstruck_foundry.behat.factory_resolver' => $this->factoryResolver, + '.zenstruck_foundry.behat.object_registry' => $this->objectRegistry, + default => throw new \InvalidArgumentException("Unknown service: {$id}"), + }); + + $kernel = $this->createStub(KernelInterface::class); + $kernel->method('getContainer')->willReturn($container); + + return new FoundryCallFilter($kernel); + } + + /** + * @param array $arguments + */ + private function createDefinitionCallForFoundryContext(array $arguments): DefinitionCall + { + $reflection = new \ReflectionMethod(FoundryContext::class, 'createObjectWithProperties'); + + $definition = $this->createStub(Definition::class); + $definition->method('getReflection')->willReturn($reflection); + + $environment = $this->createStub(Environment::class); + $feature = $this->createStub(FeatureNode::class); + $step = $this->createStub(StepNode::class); + + return new DefinitionCall( + $environment, $feature, $step, $definition, $arguments + ); + } +} + +class TestEntity +{ + public function __construct( + public int $id, public string $name, + ) { + } +} + +class DatedEntity +{ + public function __construct( + public int $id, public \DateTimeImmutable $createdAt, + ) { + } +} + +class EnumEntity +{ + public function __construct( + public int $id, public TestStatus $status, public TestPriority $priority, + ) { + } +} + +class RelationEntity +{ + public function __construct( + public int $id, public TestEntity $relation, + ) { + } +} + +abstract class ParentEntity +{ + public string $inheritedProperty = ''; +} + +class ChildEntity extends ParentEntity +{ + public function __construct( + public int $id, + ) { + } +} + +enum TestStatus: string +{ + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; +} + +enum TestPriority: int +{ + case LOW = 1; + case MEDIUM = 2; + case HIGH = 3; +} + +/** @extends ObjectFactory */ +#[FactoryShortName('test entity')] +final class TestEntityFactory extends ObjectFactory +{ + public static function class(): string + { + return TestEntity::class; + } + + protected function defaults(): array + { + return ['id' => 1, 'name' => 'Test']; + } +} + +/** @extends ObjectFactory */ +#[FactoryShortName('dated entity')] +final class DatedEntityFactory extends ObjectFactory +{ + public static function class(): string + { + return DatedEntity::class; + } + + protected function defaults(): array + { + return ['id' => 1, 'createdAt' => new \DateTimeImmutable()]; + } +} + +/** @extends ObjectFactory */ +#[FactoryShortName('enum entity')] +final class EnumEntityFactory extends ObjectFactory +{ + public static function class(): string + { + return EnumEntity::class; + } + + protected function defaults(): array + { + return ['id' => 1, 'status' => TestStatus::ACTIVE, 'priority' => TestPriority::LOW]; + } +} + +/** @extends ObjectFactory */ +#[FactoryShortName('relation entity')] +final class RelationEntityFactory extends ObjectFactory +{ + public static function class(): string + { + return RelationEntity::class; + } + + protected function defaults(): array + { + return ['id' => 1, 'relation' => new TestEntity(id: 0, name: 'default')]; + } +} + +/** @extends ObjectFactory */ +#[FactoryShortName('child entity')] +final class ChildEntityFactory extends ObjectFactory +{ + public static function class(): string + { + return ChildEntity::class; + } + + protected function defaults(): array + { + return ['id' => 1, 'inheritedProperty' => 'default']; + } +} diff --git a/src/Test/Behat/tests/Unit/FoundryTableNodeTest.php b/src/Test/Behat/tests/Unit/FoundryTableNodeTest.php new file mode 100644 index 000000000..d0c6c4d51 --- /dev/null +++ b/src/Test/Behat/tests/Unit/FoundryTableNodeTest.php @@ -0,0 +1,242 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Unit\Test\Behat; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Zenstruck\Foundry\ObjectFactory; +use Zenstruck\Foundry\Persistence\PersistenceManager; +use Zenstruck\Foundry\Test\Behat\Attribute\FactoryShortName; +use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; +use Zenstruck\Foundry\Test\Behat\FoundryTableNode; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; + +final class FoundryTableNodeTest extends TestCase +{ + private FactoryShortNameResolver $factoryResolver; + private ObjectRegistry $objectRegistry; + + protected function setUp(): void + { + $this->factoryResolver = new FactoryShortNameResolver([new TableTestEntityFactory()]); + + $persistenceManager = $this->createStub(PersistenceManager::class); + $persistenceManager->method('getIdentifierValues')->willReturnCallback( + static fn(object $object): array => ['id' => $object->id ?? 0] + ); + + $this->objectRegistry = new ObjectRegistry($this->factoryResolver, $persistenceManager); + $this->objectRegistry->reset(); + } + + #[Test] + public function it_creates_table_node_with_factory_method(): void + { + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 10, 1 => 10], + [ + 0 => ['name', 'value'], + 1 => ['foo', 'bar'], + ] + ); + + self::assertSame([['name' => 'foo', 'value' => 'bar']], $table->getColumnsHash()); + } + + #[Test] + #[DataProvider('valueFormattingProvider')] + public function it_formats_scalar_values_as_string(mixed $value, string $expected): void + { + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 20], + [ + 0 => ['col'], + 1 => [$value], + ] + ); + + $row = $table->getRowAsString(1); + + self::assertStringContainsString($expected, $row); + } + + public static function valueFormattingProvider(): iterable + { + yield 'string value' => ['hello', 'hello']; + yield 'integer value' => [42, '42']; + yield 'float value' => [3.14, '3.14']; + yield 'boolean true' => [true, '1']; + yield 'boolean false' => [false, '']; + yield 'null value' => [null, '']; + } + + #[Test] + public function it_formats_datetime_value(): void + { + $date = new \DateTimeImmutable('2024-06-15 14:30:45'); + + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 30], + [ + 0 => ['date'], + 1 => [$date], + ] + ); + + $row = $table->getRowAsString(1); + + self::assertStringContainsString('2024-06-15 14:30:45', $row); + } + + #[Test] + public function it_formats_backed_enum_value(): void + { + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 20], + [ + 0 => ['status'], + 1 => [TableTestStatus::ACTIVE], + ] + ); + + $row = $table->getRowAsString(1); + + self::assertStringContainsString('active', $row); + } + + #[Test] + public function it_formats_int_backed_enum_value(): void + { + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 20], + [ + 0 => ['priority'], + 1 => [TableTestPriority::HIGH], + ] + ); + + $row = $table->getRowAsString(1); + + self::assertStringContainsString('3', $row); + } + + #[Test] + public function it_formats_stored_object_with_factory_name_and_registry_name(): void + { + $entity = new TableTestEntity(id: 1, name: 'Test'); + $this->objectRegistry->store($entity, 'my-entity'); + + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 30], + [ + 0 => ['entity'], + 1 => [$entity], + ] + ); + + $row = $table->getRowAsString(1); + + // Should contain the short name and the registered name + self::assertStringContainsString('table test entity', $row); + self::assertStringContainsString('my-entity', $row); + } + + #[Test] + public function it_throws_for_unsupported_object_type(): void + { + $unsupportedObject = new \stdClass(); + + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 20], + [ + 0 => ['obj'], + 1 => [$unsupportedObject], + ] + ); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Unsupported value type: stdClass'); + + $table->getRowAsString(1); + } + + #[Test] + public function it_formats_row_with_wrapped_values(): void + { + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 10, 1 => 10], + [ + 0 => ['a', 'b'], + 1 => ['foo', 'bar'], + ] + ); + + $row = $table->getRowAsStringWithWrappedValues(1, static fn(string $value, int $column) => "[{$column}:{$value}]"); + + self::assertStringContainsString('[0:', $row); + self::assertStringContainsString('[1:', $row); + } +} + +class TableTestEntity +{ + public function __construct( + public int $id, + public string $name, + ) { + } +} + +enum TableTestStatus: string +{ + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; +} + +enum TableTestPriority: int +{ + case LOW = 1; + case MEDIUM = 2; + case HIGH = 3; +} + +/** @extends ObjectFactory */ +#[FactoryShortName('table test entity')] +final class TableTestEntityFactory extends ObjectFactory +{ + public static function class(): string + { + return TableTestEntity::class; + } + + protected function defaults(): array + { + return ['id' => 1, 'name' => 'Test']; + } +} diff --git a/src/Test/Behat/tests/Unit/Listener/StepTranslationsListenerTest.php b/src/Test/Behat/tests/Unit/Listener/StepTranslationsListenerTest.php new file mode 100644 index 000000000..912e49a44 --- /dev/null +++ b/src/Test/Behat/tests/Unit/Listener/StepTranslationsListenerTest.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Unit\Listener; + +use Behat\Testwork\Environment\Environment; +use Behat\Testwork\EventDispatcher\Event\BeforeSuiteTested; +use Behat\Testwork\Specification\SpecificationIterator; +use Behat\Testwork\Suite\Suite; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Loader\ArrayLoader; +use Symfony\Component\Translation\Loader\XliffFileLoader; +use Symfony\Component\Translation\Translator; +use Zenstruck\Foundry\Test\Behat\Exception\UnsupportedTranslationResource; +use Zenstruck\Foundry\Test\Behat\Listener\StepTranslationsListener; + +final class StepTranslationsListenerTest extends TestCase +{ + private const CANONICAL_PATTERN = 'there is a(n) :factoryShortName named :objectName'; + private const OVERRIDING_PATTERN = 'create a :factoryShortName called :objectName'; + + #[Test] + public function it_registers_inline_step_overrides_scoped_to_the_suite(): void + { + $translator = self::createTranslator(); + + $listener = new StepTranslationsListener( + $translator, + [self::CANONICAL_PATTERN => self::OVERRIDING_PATTERN], + [], + 'en', + ); + + $listener->registerTranslations(self::createEvent('my-suite')); + + self::assertSame(self::OVERRIDING_PATTERN, $translator->trans(self::CANONICAL_PATTERN, [], 'my-suite', 'en')); + // another suite (i.e. another translation domain) is left untouched + self::assertSame(self::CANONICAL_PATTERN, $translator->trans(self::CANONICAL_PATTERN, [], 'another-suite', 'en')); + } + + #[Test] + public function it_registers_overrides_from_a_translation_file(): void + { + $translator = self::createTranslator(); + + $listener = new StepTranslationsListener( + $translator, + [], + [__DIR__.'/../../Fixture/translations/foundry-steps.xliff'], + 'en', + ); + + $listener->registerTranslations(self::createEvent('my-suite')); + + self::assertSame(self::OVERRIDING_PATTERN, $translator->trans(self::CANONICAL_PATTERN, [], 'my-suite', 'en')); + } + + #[Test] + public function it_applies_overrides_to_the_configured_locale(): void + { + $translator = self::createTranslator(); + + $listener = new StepTranslationsListener( + $translator, + [self::CANONICAL_PATTERN => 'il existe un(e) :factoryShortName nommé :objectName'], + [], + 'fr', + ); + + $listener->registerTranslations(self::createEvent('my-suite')); + + self::assertSame('il existe un(e) :factoryShortName nommé :objectName', $translator->trans(self::CANONICAL_PATTERN, [], 'my-suite', 'fr')); + // the "en" catalogue (i.e. default ".feature" language) is not affected + self::assertSame(self::CANONICAL_PATTERN, $translator->trans(self::CANONICAL_PATTERN, [], 'my-suite', 'en')); + } + + #[Test] + public function it_throws_on_an_unsupported_translation_resource(): void + { + $listener = new StepTranslationsListener(self::createTranslator(), [], ['/path/to/steps.txt'], 'en'); + + $this->expectException(UnsupportedTranslationResource::class); + + $listener->registerTranslations(self::createEvent('my-suite')); + } + + private static function createTranslator(): Translator + { + $translator = new Translator('en'); + $translator->addLoader('array', new ArrayLoader()); + $translator->addLoader('xliff', new XliffFileLoader()); + + return $translator; + } + + private static function createEvent(string $suiteName): BeforeSuiteTested + { + $suite = self::createStub(Suite::class); + $suite->method('getName')->willReturn($suiteName); + + $environment = self::createStub(Environment::class); + $environment->method('getSuite')->willReturn($suite); + + return new BeforeSuiteTested($environment, self::createStub(SpecificationIterator::class)); + } +} diff --git a/src/Test/Behat/tests/Unit/ObjectRegistryTest.php b/src/Test/Behat/tests/Unit/ObjectRegistryTest.php new file mode 100644 index 000000000..6e8ae998c --- /dev/null +++ b/src/Test/Behat/tests/Unit/ObjectRegistryTest.php @@ -0,0 +1,346 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Unit\Test\Behat; + +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\Uuid; +use Zenstruck\Foundry\ObjectFactory; +use Zenstruck\Foundry\Persistence\Event\AfterPersist; +use Zenstruck\Foundry\Persistence\PersistenceManager; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Story\Event\StateAddedToStory; +use Zenstruck\Foundry\Test\Behat\Exception\ObjectAlreadyRegistered; +use Zenstruck\Foundry\Test\Behat\Exception\ObjectNotFound; +use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; + +final class ObjectRegistryTest extends TestCase +{ + private ObjectRegistry $registry; + private FactoryShortNameResolver $resolver; + private PersistenceManager $persistenceManager; + + protected function setUp(): void + { + $this->resolver = new FactoryShortNameResolver([new UserFactory()]); + $this->persistenceManager = $this->createStub(PersistenceManager::class); + $this->persistenceManager->method('getIdentifierValues')->willReturnCallback( + static function(object $object): array { + \assert($object instanceof User); + + return ['id' => $object->id]; + } + ); + $this->registry = new ObjectRegistry($this->resolver, $this->persistenceManager); + $this->registry->reset(); + } + + #[Test] + public function it_stores_an_object(): void + { + $user = new User(id: 1, name: 'John'); + + $this->registry->store($user, 'john'); + + self::assertTrue($this->registry->has(User::class, 'john')); + } + + #[Test] + public function it_throws_when_storing_duplicate_object_name(): void + { + $user1 = new User(id: 1, name: 'John'); + $user2 = new User(id: 2, name: 'Jane'); + + $this->registry->store($user1, 'john'); + + $this->expectException(ObjectAlreadyRegistered::class); + $this->expectExceptionMessage('Object "john" is already registered for class "Zenstruck\Foundry\Test\Behat\Tests\Unit\Test\Behat\User".'); + + $this->registry->store($user2, 'john'); + } + + #[Test] + public function it_allows_same_name_for_different_classes(): void + { + $user = new User(id: 1, name: 'John'); + $post = new Post(id: 1, title: 'John'); + + $this->registry->store($user, 'john'); + $this->registry->store($post, 'john'); + + self::assertTrue($this->registry->has(User::class, 'john')); + self::assertTrue($this->registry->has(Post::class, 'john')); + } + + #[Test] + public function it_checks_if_object_exists(): void + { + $user = new User(id: 1, name: 'John'); + $this->registry->store($user, 'john'); + + self::assertTrue($this->registry->has(User::class, 'john')); + self::assertFalse($this->registry->has(User::class, 'jane')); + self::assertFalse($this->registry->has(Post::class, 'john')); + } + + #[Test] + public function it_gets_stored_object(): void + { + $user = new User(id: 1, name: 'John'); + $this->registry->store($user, 'john'); + + $retrieved = $this->registry->getByObjectClass(User::class, 'john'); + + self::assertSame($user, $retrieved); + } + + #[Test] + public function it_throws_when_getting_non_existent_object(): void + { + $this->expectException(ObjectNotFound::class); + $this->expectExceptionMessage('Object of class "Zenstruck\Foundry\Test\Behat\Tests\Unit\Test\Behat\User" with name "john" was not found.'); + + $this->registry->getByObjectClass(User::class, 'john'); + } + + #[Test] + public function it_resets_all_stored_objects(): void + { + $user = new User(id: 1, name: 'John'); + $this->registry->store($user, 'john'); + + $this->registry->reset(); + + self::assertFalse($this->registry->has(User::class, 'john')); + } + + #[Test] + public function it_stores_last_id_from_after_persist_event(): void + { + $user = new User(id: 42, name: 'John'); + $event = new AfterPersist($user, [], $this->createStub(PersistentObjectFactory::class)); + + $this->registry->storeLastId($event); + + self::assertSame(42, $this->registry->lastId()); + } + + #[Test] + public function it_stores_string_id_from_after_persist_event(): void + { + $user = new User(id: 'uuid-123', name: 'John'); + $event = new AfterPersist($user, [], $this->createStub(PersistentObjectFactory::class)); + + $this->registry->storeLastId($event); + + self::assertSame('uuid-123', $this->registry->lastId()); + } + + #[Test] + public function it_stores_uuid_from_after_persist_event(): void + { + $uuid = Uuid::v7(); + + $persistenceManager = $this->createStub(PersistenceManager::class); + $persistenceManager->method('getIdentifierValues')->willReturn(['id' => $uuid]); + + $registry = new ObjectRegistry($this->resolver, $persistenceManager); + $registry->reset(); + + $user = new User(id: 1, name: 'John'); + $event = new AfterPersist($user, [], $this->createStub(PersistentObjectFactory::class)); + + $registry->storeLastId($event); + + self::assertSame($uuid->toRfc4122(), $registry->lastId()); + } + + #[Test] + public function it_throws_when_no_last_id_available(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No last id found.'); + + $this->registry->lastId(); + } + + #[Test] + public function it_resets_last_id(): void + { + $user = new User(id: 42, name: 'John'); + $event = new AfterPersist($user, [], $this->createStub(PersistentObjectFactory::class)); + $this->registry->storeLastId($event); + + $this->registry->reset(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No last id found.'); + + $this->registry->lastId(); + } + + #[Test] + public function it_gets_last_id_for_specific_factory(): void + { + $user1 = new User(id: 1, name: 'John'); + $user2 = new User(id: 2, name: 'Jane'); + + $this->registry->store($user1, 'john'); + $this->registry->store($user2, 'jane'); + + self::assertSame(2, $this->registry->lastIdFor('user')); + } + + #[Test] + public function it_stores_object_from_state_added_event(): void + { + $user = new User(id: 1, name: 'John'); + $event = new StateAddedToStory($user, 'john'); + + $this->registry->storeAfterStateAddedToStory($event); + + self::assertTrue($this->registry->has(User::class, 'john')); + self::assertSame($user, $this->registry->getByObjectClass(User::class, 'john')); + } + + #[Test] + public function it_throws_when_storing_duplicate_from_story_event(): void + { + $user1 = new User(id: 1, name: 'John'); + $user2 = new User(id: 2, name: 'Jane'); + + $event1 = new StateAddedToStory($user1, 'duplicate'); + $event2 = new StateAddedToStory($user2, 'duplicate'); + + $this->registry->storeAfterStateAddedToStory($event1); + + $this->expectException(ObjectAlreadyRegistered::class); + $this->expectExceptionMessage('Object "duplicate" is already registered for class "Zenstruck\Foundry\Test\Behat\Tests\Unit\Test\Behat\User".'); + + $this->registry->storeAfterStateAddedToStory($event2); + } + + #[Test] + public function it_throws_when_no_objects_for_factory(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No object of type "user" found.'); + + $this->registry->lastIdFor('user'); + } + + #[Test] + public function it_throws_when_entity_has_multiple_identifiers(): void + { + $persistenceManager = $this->createStub(PersistenceManager::class); + $persistenceManager->method('getIdentifierValues')->willReturn(['id1' => 1, 'id2' => 2]); + + $registry = new ObjectRegistry($this->resolver, $persistenceManager); + + $user = new User(id: 42, name: 'John'); + $event = new AfterPersist($user, [], $this->createStub(PersistentObjectFactory::class)); + + $registry->storeLastId($event); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot get last id: generic entity must have exactly one identifier.'); + + $registry->lastId(); + } + + #[Test] + public function it_throws_when_id_type_is_invalid(): void + { + $persistenceManager = $this->createStub(PersistenceManager::class); + $persistenceManager->method('getIdentifierValues')->willReturn(['id' => ['invalid']]); + + $registry = new ObjectRegistry($this->resolver, $persistenceManager); + + $user = new User(id: 42, name: 'John'); + $event = new AfterPersist($user, [], $this->createStub(PersistentObjectFactory::class)); + + $registry->storeLastId($event); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Wrong type for the id: expected int, string or Uid, got "array".'); + + $registry->lastId(); + } + + #[Test] + public function it_checks_if_object_is_stored(): void + { + $user = new User(id: 1, name: 'John'); + $otherUser = new User(id: 2, name: 'Jane'); + + $this->registry->store($user, 'john'); + + self::assertTrue($this->registry->isStored($user)); + self::assertFalse($this->registry->isStored($otherUser)); + } + + #[Test] + public function it_gets_name_for_stored_object(): void + { + $user = new User(id: 1, name: 'John'); + $this->registry->store($user, 'john-doe'); + + self::assertSame('john-doe', $this->registry->getNameFor($user)); + } + + #[Test] + public function it_throws_when_getting_name_for_unstored_object(): void + { + $user = new User(id: 1, name: 'John'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Object is not stored in the registry.'); + + $this->registry->getNameFor($user); + } +} + +final class User +{ + public function __construct( + public int|string $id, + public string $name, + ) { + } +} + +final class Post +{ + public function __construct( + public int $id, + public string $title, + ) { + } +} + +/** @extends ObjectFactory */ +final class UserFactory extends ObjectFactory +{ + public static function class(): string + { + return User::class; + } + + protected function defaults(): array + { + return [ + 'id' => 1, + 'name' => 'John Doe', + ]; + } +} diff --git a/src/Test/Behat/tests/bootstrap.php b/src/Test/Behat/tests/bootstrap.php new file mode 100644 index 000000000..a5e4583c6 --- /dev/null +++ b/src/Test/Behat/tests/bootstrap.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\Dotenv\Dotenv; +use Symfony\Component\Filesystem\Filesystem; + +$command = \implode(' ', $_SERVER['argv']); + +require \dirname(__DIR__).'/vendor/autoload.php'; + +$fs = new Filesystem(); + +$fs->remove(__DIR__.'/../var/cache'); + +(new Dotenv())->usePutenv()->loadEnv(__DIR__.'/../.env', testEnvs: []); diff --git a/src/ZenstruckFoundryBundle.php b/src/ZenstruckFoundryBundle.php index 354a5fb33..9b896b9ac 100644 --- a/src/ZenstruckFoundryBundle.php +++ b/src/ZenstruckFoundryBundle.php @@ -33,6 +33,7 @@ use Zenstruck\Foundry\ORM\ResetDatabase\OrmResetter; use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; use Zenstruck\Foundry\ORM\ResetDatabase\SchemaDatabaseResetter; +use Zenstruck\Foundry\Test\Behat\DependencyInjection\BehatServicesCompilerPass; /** * @author Kevin Bond @@ -274,6 +275,10 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass($this); $container->addCompilerPass(new InMemoryCompilerPass()); $container->addCompilerPass(new AsFixtureStoryCompilerPass()); + + if (\class_exists(BehatServicesCompilerPass::class)) { + $container->addCompilerPass(new BehatServicesCompilerPass()); // @phpstan-ignore argument.type + } } public function process(ContainerBuilder $container): void diff --git a/tests/Fixture/App/Controller/HelloWorldController.php b/tests/Fixture/App/Controller/HelloWorldController.php new file mode 100644 index 000000000..d1325741e --- /dev/null +++ b/tests/Fixture/App/Controller/HelloWorldController.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\App\Controller; + +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\Routing\Attribute\Route; + +#[AsController] +final class HelloWorldController +{ + #[Route('/')] + public function index(): Response + { + return new Response('Hello World'); + } +} diff --git a/tests/Fixture/Factories/Entity/Contact/ChildContactFactory.php b/tests/Fixture/Factories/Entity/Contact/ChildContactFactory.php index baeb89e94..4eb6cfa7f 100644 --- a/tests/Fixture/Factories/Entity/Contact/ChildContactFactory.php +++ b/tests/Fixture/Factories/Entity/Contact/ChildContactFactory.php @@ -11,8 +11,10 @@ namespace Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Contact; +use Zenstruck\Foundry\Test\Behat\Attribute\FactoryShortName; use Zenstruck\Foundry\Tests\Fixture\Entity\ChildContact; +#[FactoryShortName('child of contact', 'children of contact')] // @phpstan-ignore attribute.notFound final class ChildContactFactory extends ContactFactory { public static function class(): string diff --git a/tests/Fixture/FoundryTestKernel.php b/tests/Fixture/FoundryTestKernel.php index 82ed8d557..b58b5e0fa 100644 --- a/tests/Fixture/FoundryTestKernel.php +++ b/tests/Fixture/FoundryTestKernel.php @@ -20,7 +20,9 @@ use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; use Zenstruck\Foundry\Persistence\PersistenceManager; use Zenstruck\Foundry\Tests\Fixture\DoctrineCascadeRelationship\ChangeCascadePersistOnLoadClassMetadataListener; use Zenstruck\Foundry\ZenstruckFoundryBundle; @@ -81,7 +83,7 @@ public static function canUseLegacyProxy(): bool return \trait_exists(\Symfony\Component\VarExporter\LazyProxyTrait::class); } - protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + protected function configureContainer(ContainerConfigurator $configurator, LoaderInterface $loader, ContainerBuilder $c): void { $frameworkConfiguration = [ 'http_method_override' => false, @@ -113,14 +115,14 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'Entity' => [ 'is_bundle' => false, 'type' => 'attribute', - 'dir' => '%kernel.project_dir%/tests/Fixture/Entity', + 'dir' => "{$this->baseFixturePath()}/Entity", 'prefix' => 'Zenstruck\Foundry\Tests\Fixture\Entity', 'alias' => 'Entity', ], 'Model' => [ 'is_bundle' => false, 'type' => 'attribute', - 'dir' => '%kernel.project_dir%/tests/Fixture/Model', + 'dir' => "{$this->baseFixturePath()}/Model", 'prefix' => 'Zenstruck\Foundry\Tests\Fixture\Model', 'alias' => 'Model', ], @@ -132,7 +134,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'EntityInAnotherSchema' => [ 'is_bundle' => false, 'type' => 'attribute', - 'dir' => '%kernel.project_dir%/tests/Fixture/EntityInAnotherSchema', + 'dir' => "{$this->baseFixturePath()}/EntityInAnotherSchema", 'prefix' => 'Zenstruck\Foundry\Tests\Fixture\EntityInAnotherSchema', 'alias' => 'Migrate', ], @@ -175,14 +177,14 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'Document' => [ 'is_bundle' => false, 'type' => 'attribute', - 'dir' => '%kernel.project_dir%/tests/Fixture/Document', + 'dir' => "{$this->baseFixturePath()}/Document", 'prefix' => 'Zenstruck\Foundry\Tests\Fixture\Document', 'alias' => 'Document', ], 'Model' => [ 'is_bundle' => false, 'type' => 'attribute', - 'dir' => '%kernel.project_dir%/tests/Fixture/Model', + 'dir' => "{$this->baseFixturePath()}/Model", 'prefix' => 'Zenstruck\Foundry\Tests\Fixture\Model', 'alias' => 'Model', ], @@ -194,4 +196,14 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $c->register('logger', NullLogger::class); } + + protected function configureRoutes(RoutingConfigurator $routes): void + { + $routes->import(__DIR__.'/App/Controller/*.php', 'attribute'); + } + + protected function baseFixturePath(): string + { + return '%kernel.project_dir%/tests/Fixture'; + } } diff --git a/tests/Fixture/SomeEnum.php b/tests/Fixture/IntBackedEnum.php similarity index 78% rename from tests/Fixture/SomeEnum.php rename to tests/Fixture/IntBackedEnum.php index a874a6fbc..7eaded25f 100644 --- a/tests/Fixture/SomeEnum.php +++ b/tests/Fixture/IntBackedEnum.php @@ -11,7 +11,8 @@ namespace Zenstruck\Foundry\Tests\Fixture; -enum SomeEnum +enum IntBackedEnum: int { - case SOME_VALUE; + case SOME_VALUE_0 = 0; + case SOME_VALUE_1 = 1; } diff --git a/tests/Fixture/Maker/expected/can_create_factory_with_all_fields.php b/tests/Fixture/Maker/expected/can_create_factory_with_all_fields.php index 2842244da..57187de04 100644 --- a/tests/Fixture/Maker/expected/can_create_factory_with_all_fields.php +++ b/tests/Fixture/Maker/expected/can_create_factory_with_all_fields.php @@ -13,6 +13,8 @@ use Zenstruck\Foundry\Persistence\PersistentObjectFactory; use Zenstruck\Foundry\Tests\Fixture\Entity\GenericEntity; +use Zenstruck\Foundry\Tests\Fixture\IntBackedEnum; +use Zenstruck\Foundry\Tests\Fixture\StringBackedEnum; /** * @extends PersistentObjectFactory @@ -43,9 +45,14 @@ public static function class(): string protected function defaults(): array|callable { return [ + 'bool' => self::faker()->boolean(), 'date' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), + 'dateMutable' => self::faker()->dateTime(), + 'float' => self::faker()->randomFloat(), + 'intEnum' => self::faker()->randomElement(IntBackedEnum::cases()), 'prop1' => self::faker()->text(), 'propInteger' => self::faker()->randomNumber(), + 'stringEnum' => self::faker()->randomElement(StringBackedEnum::cases()), ]; } diff --git a/tests/Fixture/Maker/expected/can_create_factory_with_default_enum.php b/tests/Fixture/Maker/expected/can_create_factory_with_default_enum.php index b8f26e75f..2251632d9 100644 --- a/tests/Fixture/Maker/expected/can_create_factory_with_default_enum.php +++ b/tests/Fixture/Maker/expected/can_create_factory_with_default_enum.php @@ -13,7 +13,7 @@ use Zenstruck\Foundry\ObjectFactory; use Zenstruck\Foundry\Tests\Fixture\ObjectWithEnum; -use Zenstruck\Foundry\Tests\Fixture\SomeEnum; +use Zenstruck\Foundry\Tests\Fixture\StringBackedEnum; /** * @extends ObjectFactory @@ -44,7 +44,7 @@ public static function class(): string protected function defaults(): array|callable { return [ - 'someEnum' => self::faker()->randomElement(SomeEnum::cases()), + 'someEnum' => self::faker()->randomElement(StringBackedEnum::cases()), ]; } diff --git a/tests/Fixture/Model/GenericModel.php b/tests/Fixture/Model/GenericModel.php index 7ac87edcc..a9a776b53 100644 --- a/tests/Fixture/Model/GenericModel.php +++ b/tests/Fixture/Model/GenericModel.php @@ -13,6 +13,8 @@ use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB; use Doctrine\ORM\Mapping as ORM; +use Zenstruck\Foundry\Tests\Fixture\IntBackedEnum; +use Zenstruck\Foundry\Tests\Fixture\StringBackedEnum; /** * Used for ORM/Mongo tests. @@ -29,6 +31,26 @@ abstract class GenericModel #[MongoDB\Id(type: 'int', strategy: 'INCREMENT')] public ?int $id = null; + #[ORM\Column(nullable: true)] + #[MongoDB\Field(type: 'date', nullable: true)] + public ?\DateTime $dateMutable = null; + + #[ORM\Column(nullable: true)] + #[MongoDB\Field(type: 'bool', nullable: true)] + public ?bool $bool = null; + + #[ORM\Column(name: '`float`', nullable: true)] + #[MongoDB\Field(type: 'float', nullable: true)] + public ?float $float = null; + + #[ORM\Column(nullable: true)] + #[MongoDB\Field(type: 'string', nullable: true, enumType: StringBackedEnum::class)] + public ?StringBackedEnum $stringEnum = null; + + #[ORM\Column(nullable: true)] + #[MongoDB\Field(type: 'int', nullable: true, enumType: IntBackedEnum::class)] + public ?IntBackedEnum $intEnum = null; + #[ORM\Column] #[MongoDB\Field(type: 'string')] private ?string $prop1 = null; // @phpstan-ignore doctrine.columnType (made on purpose, Doctrine does not handle nullable properties the same way) diff --git a/tests/Fixture/ObjectWithEnum.php b/tests/Fixture/ObjectWithEnum.php index 5f70aaddf..f1bf6c5d5 100644 --- a/tests/Fixture/ObjectWithEnum.php +++ b/tests/Fixture/ObjectWithEnum.php @@ -14,7 +14,7 @@ final class ObjectWithEnum { public function __construct( - public readonly SomeEnum $someEnum, + public readonly StringBackedEnum $someEnum, ) { } } diff --git a/tests/Fixture/ResetDatabase/ResetDatabaseTestKernel.php b/tests/Fixture/ResetDatabase/ResetDatabaseTestKernel.php index af3fe79b4..cdd848e0c 100644 --- a/tests/Fixture/ResetDatabase/ResetDatabaseTestKernel.php +++ b/tests/Fixture/ResetDatabase/ResetDatabaseTestKernel.php @@ -14,6 +14,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; use Zenstruck\Foundry\Tests\Fixture\FoundryTestKernel; use Zenstruck\Foundry\Tests\Fixture\Stories\GlobalInvokableService; @@ -38,9 +39,9 @@ public static function usesSqlite(): bool return \str_starts_with((string) \getenv('DATABASE_URL'), 'sqlite:'); } - protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + protected function configureContainer(ContainerConfigurator $configurator, LoaderInterface $loader, ContainerBuilder $c): void { - parent::configureContainer($c, $loader); + parent::configureContainer($configurator, $loader, $c); $c->loadFromExtension('zenstruck_foundry', [ 'persistence' => ['flush_once' => true], diff --git a/tests/Fixture/StringBackedEnum.php b/tests/Fixture/StringBackedEnum.php new file mode 100644 index 000000000..195497023 --- /dev/null +++ b/tests/Fixture/StringBackedEnum.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture; + +enum StringBackedEnum: string +{ + case SOME_VALUE = 'some_value'; + case OTHER_VALUE = 'other_value'; +} diff --git a/tests/Fixture/TestKernel.php b/tests/Fixture/TestKernel.php index 66ce291a1..ff8c80f92 100644 --- a/tests/Fixture/TestKernel.php +++ b/tests/Fixture/TestKernel.php @@ -14,7 +14,7 @@ use Symfony\Bundle\MakerBundle\MakerBundle; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; use Zenstruck\Foundry\Tests\Fixture\App\Command\UpdateGenericModelCommand; use Zenstruck\Foundry\Tests\Fixture\App\Controller\CreateContact; @@ -50,9 +50,9 @@ public function registerBundles(): iterable yield new MakerBundle(); } - protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + protected function configureContainer(ContainerConfigurator $configurator, LoaderInterface $loader, ContainerBuilder $c): void { - parent::configureContainer($c, $loader); + parent::configureContainer($configurator, $loader, $c); $c->loadFromExtension('zenstruck_foundry', [ 'persistence' => ['flush_once' => true], @@ -98,9 +98,4 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $c->register(HelloWorld::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); $c->register(UpdateGenericModelCommand::class)->setAutowired(true)->setAutoconfigured(true); } - - protected function configureRoutes(RoutingConfigurator $routes): void - { - $routes->import(__DIR__.'/App/Controller/*.php', 'attribute'); - } } diff --git a/tests/Integration/Command/LoadFixturesCommandTest.php b/tests/Integration/Command/LoadFixturesCommandTest.php index 4be4a1988..2abe43a70 100644 --- a/tests/Integration/Command/LoadFixturesCommandTest.php +++ b/tests/Integration/Command/LoadFixturesCommandTest.php @@ -15,10 +15,10 @@ use PHPUnit\Framework\Attributes\Test; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Tester\CommandTester; +use Zenstruck\Foundry\Story\FixtureStoryNotFound; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; use Zenstruck\Foundry\Tests\Fixture\Entity\GlobalEntity; @@ -52,8 +52,8 @@ public function it_throws_if_no_story_marked_as_fixture(): void #[Test] public function it_throws_if_story_does_not_exist(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Story with name "invalid-name" does not exist'); + $this->expectException(FixtureStoryNotFound::class); + $this->expectExceptionMessage('Fixture story with name or group "invalid-name" not found'); $this->commandTester(['environment' => 'stories_as_fixtures'])->execute(['name' => 'invalid-name', '--append' => true]); } @@ -286,8 +286,10 @@ private function commandTester(array $options = []): CommandTester 'foundry:load-story', ]; - return new CommandTester((new Application(self::bootKernel($options)))->find( - $commands[\array_rand($commands)] - )); + return new CommandTester( + (new Application(self::bootKernel($options)))->find( + $commands[\array_rand($commands)] + ) + ); } } diff --git a/tests/Integration/ResetDatabase/ResetDatabaseTestCase.php b/tests/Integration/ResetDatabase/ResetDatabaseTestCase.php index afeb9221e..8c43e53e9 100644 --- a/tests/Integration/ResetDatabase/ResetDatabaseTestCase.php +++ b/tests/Integration/ResetDatabase/ResetDatabaseTestCase.php @@ -13,6 +13,7 @@ namespace Zenstruck\Foundry\Tests\Integration\ResetDatabase; +use Composer\InstalledVersions; use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\Attributes\Test; use Symfony\Bundle\FrameworkBundle\Console\Application; @@ -39,10 +40,14 @@ public function it_generates_valid_schema(): void $application = new Application(self::bootKernel()); $application->setAutoExit(false); - $exit = $application->run( - new ArrayInput(['command' => 'doctrine:schema:validate', '-v' => true]), - $output = new BufferedOutput() - ); + $parameters = ['command' => 'doctrine:schema:validate', '-v' => true]; + + // enums in GenericModel are not well handled with --prefer-lowest + if (\version_compare(InstalledVersions::getVersion('doctrine/orm') ?? '', '3.0', '<')) { + $parameters['--skip-mapping'] = true; + } + + $exit = $application->run(new ArrayInput($parameters), $output = new BufferedOutput()); if (FoundryTestKernel::usesMigrations()) { // The command actually fails, because of a bug in doctrine ORM 3!