diff --git a/.github/workflows/benchmark-pr.yml b/.github/workflows/benchmark-pr.yml index 1fc2ce6b1..fa7a831b5 100644 --- a/.github/workflows/benchmark-pr.yml +++ b/.github/workflows/benchmark-pr.yml @@ -16,7 +16,7 @@ jobs: fail-fast: true # Fail the entire workflow if any job fails matrix: operating-system: [ ubuntu-latest ] - php-versions: [ 8.3 ] + php-versions: [ 8.5 ] stability: [prefer-stable] services: rabbitmq: @@ -74,7 +74,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - extensions: grpc, rdkafka + extensions: rdkafka coverage: none - uses: actions/checkout@v3 @@ -82,7 +82,7 @@ jobs: ref: main - name: Install dependencies - run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction + run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --ignore-platform-reqs - name: Benchmark main as baseline id: baseline @@ -99,7 +99,7 @@ jobs: git clean -fxd -e .phpbench -e vendor - name: Install dependencies - run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction + run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --ignore-platform-reqs - name: Benchmark PR shell: bash diff --git a/.github/workflows/file-licence.yml b/.github/workflows/file-licence.yml index d59767213..3a1b8d711 100644 --- a/.github/workflows/file-licence.yml +++ b/.github/workflows/file-licence.yml @@ -22,7 +22,7 @@ jobs: - name: Set Up PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.2 + php-version: 8.5 - name: Install dependencies run: composer update --prefer-dist --no-interaction --ignore-platform-reqs diff --git a/.github/workflows/split-testing.yml b/.github/workflows/split-testing.yml index 1feec6608..013fa5907 100644 --- a/.github/workflows/split-testing.yml +++ b/.github/workflows/split-testing.yml @@ -134,6 +134,10 @@ jobs: - name: Run tests run: | COMPONENTS=$(find packages -maxdepth 2 -type f -name phpunit.xml.dist | xargs -I{} dirname {}) + # ecotone/tempest needs PHP 8.5+ (tempest/framework) and its integration tests use a + # kernel-boot harness tied to the monorepo layout; it is covered by the unified Monorepo + # job, so exclude it from the isolated per-package Split Testing matrix. + COMPONENTS=$(echo "$COMPONENTS" | grep -v 'packages/Tempest') RUN_TESTS="composer tests:ci" if [ "${{ matrix.stability }}" = "prefer-lowest" ]; then @@ -210,8 +214,9 @@ jobs: overall_exit_code=1 fi - # Run SQLite pass (skip for PdoEventSourcing and DataProtection - SQLite event store not supported) - if [[ ! "$dir" =~ (PdoEventSourcing|DataProtection) ]]; then + # Run SQLite pass (skip for PdoEventSourcing and DataProtection - SQLite event store not supported; + # skip Tempest - its multi-tenant tests need two distinct Postgres/MySQL engines) + if [[ ! "$dir" =~ (PdoEventSourcing|DataProtection|Tempest) ]]; then local sqlite_db="/tmp/ecotone_${slug}_test.db" local sqlite_db_secondary="/tmp/ecotone_${slug}_test_b.db" # Use 4 slashes for absolute paths: sqlite:// + / + /path = sqlite:////path diff --git a/composer.json b/composer.json index 28c76bd0b..bae2d0f26 100644 --- a/composer.json +++ b/composer.json @@ -64,6 +64,7 @@ "Ecotone\\Redis\\": "packages/Redis/src", "Ecotone\\Sqs\\": "packages/Sqs/src", "Ecotone\\Laravel\\": "packages/Laravel/src", + "Ecotone\\Tempest\\": "packages/Tempest/src", "Ecotone\\Lite\\": [ "packages/Ecotone/src/Lite/", "packages/LiteApplication/src" @@ -124,6 +125,8 @@ "packages/Kafka/tests" ], "Test\\Ecotone\\Laravel\\": "packages/Laravel/tests", + "Test\\Ecotone\\Tempest\\": "packages/Tempest/tests", + "App\\Tempest\\": "packages/Tempest/tests/app/src", "App\\MultiTenant\\": "packages/Laravel/tests/MultiTenant/app", "App\\Licence\\Laravel\\": "packages/Laravel/tests/Licence/app", "Symfony\\App\\MultiTenant\\": "packages/Symfony/tests/phpunit/MultiTenant/src", @@ -171,7 +174,7 @@ "guzzlehttp/psr7": "^2.0", "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", "php-coveralls/php-coveralls": "^2.5", - "phpstan/phpstan": "^1.8", + "phpstan/phpstan": "^1.8|^2.0", "phpunit/phpunit": "^11.0", "predis/predis": "^1.1.10", "symfony/expression-language": "^6.4|^7.0|^8.0", @@ -193,7 +196,8 @@ "kwn/php-rdkafka-stubs": "^2.2", "symfony/var-exporter": "^6.4|^7.0|^8.0", "enqueue/dsn": "^0.10.27", - "yoast/phpunit-polyfills": "^4.0.0" + "yoast/phpunit-polyfills": "^4.0.0", + "tempest/framework": "^3.11" }, "suggest": { "ext-simplexml": "Required if application/xml is used as serialization media type" @@ -226,6 +230,7 @@ "ecotone/enqueue": "1.82.0", "ecotone/jms-converter": "1.82.0", "ecotone/laravel": "1.82.0", + "ecotone/tempest": "1.82.0", "ecotone/pdo-event-sourcing": "1.82.0", "ecotone/symfony-bundle": "1.82.0" }, diff --git a/packages/Ecotone/src/Messaging/Config/ModuleClassList.php b/packages/Ecotone/src/Messaging/Config/ModuleClassList.php index 71d3b4e21..713232361 100644 --- a/packages/Ecotone/src/Messaging/Config/ModuleClassList.php +++ b/packages/Ecotone/src/Messaging/Config/ModuleClassList.php @@ -81,6 +81,7 @@ use Ecotone\Sqs\Configuration\SqsMessagePublisherModule; use Ecotone\Sqs\Configuration\SqsModule; use Ecotone\SymfonyBundle\Config\SymfonyConnectionModule; +use Ecotone\Tempest\Config\TempestConnectionModule; /** * licence Apache-2.0 @@ -202,6 +203,10 @@ class ModuleClassList SymfonyConnectionModule::class, ]; + public const TEMPEST_MODULES = [ + TempestConnectionModule::class, + ]; + public const KAFKA_MODULES = [ KafkaModule::class, ]; diff --git a/packages/Ecotone/src/Messaging/Config/ModulePackageList.php b/packages/Ecotone/src/Messaging/Config/ModulePackageList.php index 32b8e2e1d..6e784ae4b 100644 --- a/packages/Ecotone/src/Messaging/Config/ModulePackageList.php +++ b/packages/Ecotone/src/Messaging/Config/ModulePackageList.php @@ -25,6 +25,7 @@ final class ModulePackageList public const TRACING_PACKAGE = 'tracing'; public const LARAVEL_PACKAGE = 'laravel'; public const SYMFONY_PACKAGE = 'symfony'; + public const TEMPEST_PACKAGE = 'tempest'; public const TEST_PACKAGE = 'test'; public static function getModuleClassesForPackage(string $packageName): array @@ -43,6 +44,7 @@ public static function getModuleClassesForPackage(string $packageName): array ModulePackageList::TEST_PACKAGE => ModuleClassList::TEST_MODULES, ModulePackageList::LARAVEL_PACKAGE => ModuleClassList::LARAVEL_MODULES, ModulePackageList::SYMFONY_PACKAGE => ModuleClassList::SYMFONY_MODULES, + ModulePackageList::TEMPEST_PACKAGE => ModuleClassList::TEMPEST_MODULES, ModulePackageList::DATA_PROTECTION_PACKAGE => ModuleClassList::DATA_PROTECTION_MODULES, default => throw ConfigurationException::create(sprintf('Given unknown package name %s. Available packages name are: %s', $packageName, implode(',', self::allPackages()))) }; @@ -66,6 +68,7 @@ public static function allPackages(): array self::TRACING_PACKAGE, self::LARAVEL_PACKAGE, self::SYMFONY_PACKAGE, + self::TEMPEST_PACKAGE, self::DATA_PROTECTION_PACKAGE, ]; } diff --git a/packages/Ecotone/src/Messaging/MessageHeaders.php b/packages/Ecotone/src/Messaging/MessageHeaders.php index bf031b1ae..2159a126c 100644 --- a/packages/Ecotone/src/Messaging/MessageHeaders.php +++ b/packages/Ecotone/src/Messaging/MessageHeaders.php @@ -283,11 +283,8 @@ public static function unsetBusKeys(array $metadata): array { unset( $metadata[MessageBusChannel::COMMAND_CHANNEL_NAME_BY_NAME], - $metadata[MessageBusChannel::COMMAND_CHANNEL_NAME_BY_OBJECT], $metadata[MessageBusChannel::EVENT_CHANNEL_NAME_BY_NAME], - $metadata[MessageBusChannel::EVENT_CHANNEL_NAME_BY_OBJECT], - $metadata[MessageBusChannel::QUERY_CHANNEL_NAME_BY_NAME], - $metadata[MessageBusChannel::QUERY_CHANNEL_NAME_BY_OBJECT] + $metadata[MessageBusChannel::QUERY_CHANNEL_NAME_BY_NAME] ); return $metadata; diff --git a/packages/Tempest/.gitattributes b/packages/Tempest/.gitattributes new file mode 100644 index 000000000..5699823c5 --- /dev/null +++ b/packages/Tempest/.gitattributes @@ -0,0 +1,7 @@ +tests/ export-ignore +.coveralls.yml export-ignore +.gitattributes export-ignore +.gitignore export-ignore +behat.yaml export-ignore +phpstan.neon export-ignore +phpunit.xml export-ignore \ No newline at end of file diff --git a/packages/Tempest/.github/FUNDING.yml b/packages/Tempest/.github/FUNDING.yml new file mode 100644 index 000000000..c7eaae65e --- /dev/null +++ b/packages/Tempest/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: [dgafka] +patreon: # Replace with a single Open Collective username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/packages/Tempest/.github/ISSUE_TEMPLATE/bug_report.md b/packages/Tempest/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..2fc86f2cf --- /dev/null +++ b/packages/Tempest/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,10 @@ +--- +name: This is Read-Only repository +about: Report at ecotoneframework/ecotone-dev +title: '' +labels: '' +assignees: '' + +--- + +Report issue at [ecotone-dev](ecotoneframework/ecotone-dev) \ No newline at end of file diff --git a/packages/Tempest/.gitignore b/packages/Tempest/.gitignore new file mode 100644 index 000000000..18c159d80 --- /dev/null +++ b/packages/Tempest/.gitignore @@ -0,0 +1,9 @@ +.idea/ +vendor/ +bin/ +tests/coverage +!tests/coverage/.gitkeep +file +.phpunit.result.cache +composer.lock +phpunit.xml diff --git a/packages/Tempest/LICENSE b/packages/Tempest/LICENSE new file mode 100644 index 000000000..82205508a --- /dev/null +++ b/packages/Tempest/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2025 Dariusz Gafka + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +**Scope of the License** + +Apache-2.0 Licence applies to non Enterprise Functionalities of the Ecotone Framework. +Functionalities of the Ecotone Framework referred to as Enterprise functionalities, are not covered under the Apache-2.0 license. These functionalities are provided under a separate Enterprise License. +For details on the Enterprise License, please refer to the [LICENSE-ENTERPRISE](./LICENSE-ENTERPRISE) file. \ No newline at end of file diff --git a/packages/Tempest/LICENSE-ENTERPRISE b/packages/Tempest/LICENSE-ENTERPRISE new file mode 100644 index 000000000..fad1a5a8d --- /dev/null +++ b/packages/Tempest/LICENSE-ENTERPRISE @@ -0,0 +1,3 @@ +Copyright (c) 2025 Dariusz Gafka + +Licence is available at [ecotone.tech/documents/ecotone_enterprise_licence.pdf](https://ecotone.tech/documents/ecotone_enterprise_licence.pdf) \ No newline at end of file diff --git a/packages/Tempest/README.md b/packages/Tempest/README.md new file mode 100644 index 000000000..7cca5506a --- /dev/null +++ b/packages/Tempest/README.md @@ -0,0 +1,324 @@ +# This is Read Only Repository +To contribute make use of [Ecotone-Dev repository](https://github.com/ecotoneframework/ecotone-dev). + +

+ +

+ +![Github Actions](https://github.com/ecotoneFramework/ecotone-dev/actions/workflows/split-testing.yml/badge.svg) +[![Latest Stable Version](https://poser.pugx.org/ecotone/tempest/v/stable)](https://packagist.org/packages/ecotone/tempest) +[![License](https://poser.pugx.org/ecotone/tempest/license)](https://packagist.org/packages/ecotone/tempest) +[![Total Downloads](https://img.shields.io/packagist/dt/ecotone/tempest)](https://packagist.org/packages/ecotone/tempest) +[![PHP Version Require](https://img.shields.io/packagist/dependency-v/ecotone/tempest/php.svg)](https://packagist.org/packages/ecotone/tempest) + +**Ecotone is the PHP architecture layer that grows with your system — without rewrites.** + +From `#[CommandHandler]` on day one, to event sourcing, sagas, outbox, and distributed messaging at scale — one package, same codebase, no forced migrations between growth stages. Declarative PHP 8 attributes on the classes you already have. + +## ecotone/tempest + +Ecotone for [Tempest](https://tempestphp.com) — CQRS, Event Sourcing, Sagas, Durable Workflows, and Outbox via PHP attributes. Zero-config auto-discovery derives your application namespaces from your `composer.json` PSR-4 roots. Handlers, aggregates, sagas, and projections are found automatically without any registration boilerplate. + +- Zero-config auto-discovery of handlers from your app's PSR-4 namespaces +- CQRS — `CommandBus`, `QueryBus`, `EventBus` available via dependency injection +- Database integration via `ecotone/dbal` with Tempest's `DatabaseConfig` +- Multi-tenant connections with per-tenant database switching +- Async messaging, sagas, outbox, event sourcing (with appropriate modules) +- Console commands: `ecotone:list`, `ecotone:run`, `ecotone:cache:clear` + +Visit [ecotone.tech](https://ecotone.tech) to learn more. + +## Installation + +```bash +composer require ecotone/tempest +``` + +Ecotone auto-discovery is enabled via Tempest's package discovery system. No service provider registration is needed. + +## Getting Started + +### Zero-Config Handler Discovery + +Ecotone derives your application namespaces from the PSR-4 roots declared in your `composer.json`. Any class with an Ecotone attribute (`#[CommandHandler]`, `#[QueryHandler]`, `#[EventHandler]`, etc.) in those namespaces is discovered automatically. + +```php +// src/Order/PlaceOrderHandler.php +namespace App\Order; + +use Ecotone\Modelling\Attribute\CommandHandler; +use Ecotone\Modelling\Attribute\QueryHandler; + +final class PlaceOrderHandler +{ + private array $orders = []; + + #[CommandHandler('order.place')] + public function place(string $orderId): void + { + $this->orders[] = $orderId; + } + + #[QueryHandler('order.all')] + public function all(): array + { + return $this->orders; + } +} +``` + +That is all that is needed. No configuration file, no registration — Ecotone discovers it from your namespace. + +### Using the Buses + +`CommandBus`, `QueryBus`, and `EventBus` are automatically registered in the Tempest container and can be injected anywhere: + +```php +use Ecotone\Modelling\CommandBus; +use Ecotone\Modelling\QueryBus; + +final class OrderController +{ + public function __construct( + private CommandBus $commandBus, + private QueryBus $queryBus, + ) {} + + public function place(string $orderId): array + { + $this->commandBus->sendWithRouting('order.place', $orderId); + + return $this->queryBus->sendWithRouting('order.all'); + } +} +``` + +## Optional Configuration + +Create a class that provides `EcotoneConfig` to the Tempest container to customise behaviour: + +```php +// src/Configuration/EcotoneConfiguration.php +namespace App\Configuration; + +use Ecotone\Tempest\EcotoneConfig; +use Tempest\Container\Singleton; + +#[Singleton] +final class EcotoneConfiguration +{ + public function ecotoneConfig(): EcotoneConfig + { + return new EcotoneConfig( + serviceName: 'my-service', + licenceKey: getenv('ECOTONE_LICENCE_KEY') ?: '', + cacheConfiguration: true, + ); + } +} +``` + +Or simply bind `EcotoneConfig` in a Tempest config file: + +```php +// config/ecotone.php (or any #[ServiceContext] provider) +use Ecotone\Tempest\EcotoneConfig; + +return new EcotoneConfig( + serviceName: 'my-service', + licenceKey: getenv('ECOTONE_LICENCE_KEY') ?: '', +); +``` + +### `EcotoneConfig` Reference + +| Property | Type | Default | Description | +|---|---|---|---| +| `serviceName` | `string` | `''` (from `ECOTONE_SERVICE_NAME` env) | Identifies this service in distributed tracing and logs | +| `namespaces` | `array` | `[]` | Explicit namespaces to scan. When empty and `loadAppNamespaces` is true, derived from `composer.json` | +| `loadAppNamespaces` | `bool` | `true` | Auto-derive scan namespaces from your app's PSR-4 roots | +| `cacheConfiguration` | `bool` | `false` (from `ECOTONE_CACHE_CONFIGURATION` env) | Cache the messaging system definition for production | +| `defaultSerializationMediaType` | `string` | `''` | Override the default message serialization format | +| `defaultErrorChannel` | `string` | `''` | Channel name for unhandled async exceptions | +| `skippedModulePackageNames` | `array` | `[]` | Module packages to skip loading (useful for testing) | +| `licenceKey` | `string` | `''` | Enterprise licence key | + +## Console Commands + +Ecotone registers Tempest console commands automatically: + +```bash +# List all registered consumers and handlers +./tempest ecotone:list + +# Run an asynchronous consumer (requires ecotone/dbal or another async transport) +./tempest ecotone:run notifications + +# Clear the Ecotone configuration cache +./tempest ecotone:cache:clear +``` + +The `ecotone:cache:clear` command removes the cached messaging system definition from `sys_get_temp_dir()/ecotone_tempest/`. Use it after deploying changes when `cacheConfiguration` is enabled. + +## Database Integration (requires `ecotone/dbal`) + +Install the DBAL module: + +```bash +composer require ecotone/dbal +``` + +### Single-Tenant Connection + +Register a `TempestConnectionReference` via `#[ServiceContext]` to bridge Tempest's `DatabaseConfig` to Ecotone's DBAL module: + +```php +use Ecotone\Messaging\Attribute\ServiceContext; +use Ecotone\Tempest\Config\TempestConnectionReference; + +final class EcotoneConfiguration +{ + #[ServiceContext] + public function dbalConnection(): TempestConnectionReference + { + return TempestConnectionReference::defaultConnection(); + } +} +``` + +`TempestConnectionReference::defaultConnection()` resolves Tempest's default `DatabaseConfig` from the container at runtime. + +To use a specific config: + +```php +use Tempest\Database\Config\PostgresConfig; + +#[ServiceContext] +public function dbalConnection(): TempestConnectionReference +{ + return TempestConnectionReference::create('myConnection', new PostgresConfig( + host: getenv('DB_HOST') ?: 'localhost', + port: getenv('DB_PORT') ?: '5432', + username: getenv('DB_USER') ?: 'app', + password: getenv('DB_PASSWORD') ?: '', + database: getenv('DB_NAME') ?: 'app', + )); +} +``` + +### Multi-Tenant Connection + +Use `MultiTenantConfiguration` together with per-tenant `TempestConnectionReference` instances. A `tenant` header on each message selects the correct database: + +```php +use Ecotone\Dbal\MultiTenant\MultiTenantConfiguration; +use Ecotone\Messaging\Attribute\ServiceContext; +use Ecotone\Tempest\Config\TempestConnectionReference; +use Tempest\Database\Config\MysqlConfig; +use Tempest\Database\Config\PostgresConfig; + +final class EcotoneConfiguration +{ + #[ServiceContext] + public function multiTenantConfiguration(): MultiTenantConfiguration + { + return MultiTenantConfiguration::create( + tenantHeaderName: 'tenant', + tenantToConnectionMapping: [ + 'tenant_a' => TempestConnectionReference::create('tenant_a', new PostgresConfig( + host: getenv('TENANT_A_DB_HOST'), + username: getenv('TENANT_A_DB_USER'), + password: getenv('TENANT_A_DB_PASSWORD'), + database: getenv('TENANT_A_DB_NAME'), + )), + 'tenant_b' => TempestConnectionReference::create('tenant_b', new MysqlConfig( + host: getenv('TENANT_B_DB_HOST'), + username: getenv('TENANT_B_DB_USER'), + password: getenv('TENANT_B_DB_PASSWORD'), + database: getenv('TENANT_B_DB_NAME'), + )), + ], + ); + } +} +``` + +Route commands to the correct tenant by setting the header: + +```php +$commandBus->sendWithRouting('order.place', $order, metadata: ['tenant' => 'tenant_a']); +``` + +> **Security note:** When `cacheConfiguration` is enabled, the `DatabaseConfig` for each +> `TempestConnectionReference::create(name, config)` call is serialized into the on-disk cache +> file. This means database credentials (username, password, DSN) are written to disk in +> base64-encoded serialized form. Keep the cache directory (`sys_get_temp_dir()/ecotone_tempest/`) +> non-world-readable and rotate credentials if the cache file is exposed. Use +> `./tempest ecotone:cache:clear` after credential rotation. + +## Production Caching + +Enable the configuration cache for production to avoid re-scanning annotations on every request: + +```php +new EcotoneConfig( + cacheConfiguration: true, +); +``` + +Or set the `ECOTONE_CACHE_CONFIGURATION=1` environment variable. The cache is stored in `sys_get_temp_dir()/ecotone_tempest/`. Clear it after deployment: + +```bash +./tempest ecotone:cache:clear +``` + +When `APP_ENV=prod` or `APP_ENV=production`, the production cache path is used automatically regardless of the `cacheConfiguration` setting. + +## Expression Language + +Ecotone supports Symfony Expression Language in `#[Payload]` and `#[Header]` attributes. The `parameter()` function reads from environment variables via `TempestConfigurationVariableService`: + +```php +use Ecotone\Messaging\Attribute\Parameter\Payload; +use Ecotone\Modelling\Attribute\CommandHandler; + +final class CalculatorHandler +{ + #[CommandHandler('calculator.multiply')] + public function multiply( + #[Payload("parameter('APP_MULTIPLIER') * payload['value']")] int $result + ): void { + // $result = APP_MULTIPLIER * payload['value'] + } +} +``` + +Requires `symfony/expression-language`: + +```bash +composer require symfony/expression-language +``` + +## Feature requests and issue reporting + +Use [issue tracking system](https://github.com/ecotoneframework/ecotone-dev/issues) for new feature request and bugs. +Please verify that it's not already reported by someone else. + +## Contact + +If you want to talk or ask questions about Ecotone + +- [**Twitter**](https://twitter.com/EcotonePHP) +- **support@simplycodedsoftware.com** +- [**Community Channel**](https://discord.gg/GwM2BSuXeg) + +## Support Ecotone + +If you want to help building and improving Ecotone consider becoming a sponsor: + +- [Sponsor Ecotone](https://github.com/sponsors/dgafka) +- [Contribute to Ecotone](https://github.com/ecotoneframework/ecotone-dev). + +## Tags + +PHP, Ecotone, Tempest, CQRS, Event Sourcing, Sagas, Durable Workflows, Outbox, Messaging, EIP, DDD diff --git a/packages/Tempest/composer.json b/packages/Tempest/composer.json new file mode 100644 index 000000000..24d87b893 --- /dev/null +++ b/packages/Tempest/composer.json @@ -0,0 +1,100 @@ +{ + "name": "ecotone/tempest", + "license": [ + "Apache-2.0", + "proprietary" + ], + "homepage": "https://docs.ecotone.tech/", + "forum": "https://discord.gg/GwM2BSuXeg", + "type": "library", + "minimum-stability": "dev", + "prefer-stable": true, + "authors": [ + { + "name": "Dariusz Gafka", + "email": "support@simplycodedsoftware.com" + } + ], + "keywords": [ + "ddd", + "cqrs", + "tempest", + "event-sourcing", + "sagas", + "durable-workflows", + "workflow", + "outbox", + "ecotone", + "messaging", + "eip" + ], + "description": "Ecotone for Tempest — CQRS, Event Sourcing, Sagas, Durable Workflows, and Outbox via PHP attributes.", + "repositories": [ + { + "type": "path", + "url": "../*", + "options": { + "symlink": true + } + } + ], + "autoload": { + "psr-4": { + "Ecotone\\Tempest\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Test\\Ecotone\\Tempest\\": "tests", + "App\\Tempest\\": "tests/app/src" + } + }, + "require": { + "php": "^8.4", + "ecotone/ecotone": "~1.315.0", + "tempest/framework": "^3.11" + }, + "suggest": { + "ecotone/dbal": "Enables DBAL integration, database repositories, multi-tenant connections via TempestConnectionReference, and the DbalModule for transactional outbox, sagas, and event sourcing backed by relational databases.", + "enqueue/dbal": "DBAL-backed message queue transport; required if you want durable async messaging over a relational database using ecotone/dbal." + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "phpstan/phpstan": "^1.8|^2.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "ecotone/dbal": "~1.315.0", + "doctrine/dbal": "^4.0" + }, + "scripts": { + "tests:phpstan": "vendor/bin/phpstan", + "tests:phpunit": [ + "vendor/bin/phpunit --no-coverage" + ], + "tests:ci": [ + "@tests:phpstan", + "@tests:phpunit" + ] + }, + "extra": { + "branch-alias": { + "dev-main": "1.315.0-dev" + }, + "ecotone": { + "repository": "tempest" + }, + "tempest": { + "can-discover": true + }, + "license-info": { + "Apache-2.0": { + "name": "Apache License 2.0", + "url": "https://github.com/ecotoneframework/ecotone-dev/blob/main/LICENSE", + "description": "Allows to use non Enterprise features of Ecotone. For more information please write to support@simplycodedsoftware.com" + }, + "proprietary": { + "name": "Enterprise License", + "description": "Allows to use Enterprise features of Ecotone. For more information please write to support@simplycodedsoftware.com" + } + } + } +} diff --git a/packages/Tempest/phpstan.neon b/packages/Tempest/phpstan.neon new file mode 100644 index 000000000..672e0fa1f --- /dev/null +++ b/packages/Tempest/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 1 + paths: + - src \ No newline at end of file diff --git a/packages/Tempest/phpunit.xml.dist b/packages/Tempest/phpunit.xml.dist new file mode 100644 index 000000000..9d5d6d5c5 --- /dev/null +++ b/packages/Tempest/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + ./src + + + + + + + + + + tests + + + diff --git a/packages/Tempest/src/Config/PDO/Connection.php b/packages/Tempest/src/Config/PDO/Connection.php new file mode 100644 index 000000000..69b8127bf --- /dev/null +++ b/packages/Tempest/src/Config/PDO/Connection.php @@ -0,0 +1,100 @@ +pdo->exec($statement); + assert($result !== false); + + return $result; + } catch (PDOException $e) { + throw Exception::new($e); + } + } + + public function prepare(string $sql): StatementInterface + { + try { + return new Statement($this->pdo->prepare($sql)); + } catch (PDOException $e) { + throw Exception::new($e); + } + } + + public function query(string $sql): ResultInterface + { + try { + $stmt = $this->pdo->query($sql); + assert($stmt instanceof PDOStatement); + + return new Result($stmt); + } catch (PDOException $e) { + throw Exception::new($e); + } + } + + public function lastInsertId(): string|int + { + try { + return $this->pdo->lastInsertId(); + } catch (PDOException $e) { + throw Exception::new($e); + } + } + + public function beginTransaction(): void + { + $this->pdo->beginTransaction(); + } + + public function commit(): void + { + $this->pdo->commit(); + } + + public function rollBack(): void + { + $this->pdo->rollBack(); + } + + public function quote(string $value): string + { + return $this->pdo->quote($value); + } + + public function getServerVersion(): string + { + return $this->pdo->getAttribute(PDO::ATTR_SERVER_VERSION); + } + + public function getNativeConnection(): PDO + { + return $this->pdo; + } +} diff --git a/packages/Tempest/src/Config/PDO/MySqlDriver.php b/packages/Tempest/src/Config/PDO/MySqlDriver.php new file mode 100644 index 000000000..5e7881142 --- /dev/null +++ b/packages/Tempest/src/Config/PDO/MySqlDriver.php @@ -0,0 +1,18 @@ +get(Connection::class); + $property = new \ReflectionProperty($connection, 'pdo'); + return $property->getValue($connection); + } + + public function prepare(string $sql): StatementInterface + { + return new Statement($this->pdo()->prepare($sql)); + } + + public function query(string $sql): ResultInterface + { + return new Result($this->pdo()->query($sql)); + } + + public function quote(string $value): string + { + return $this->pdo()->quote($value); + } + + public function exec(string $sql): int|string + { + $result = $this->pdo()->exec($sql); + return $result === false ? 0 : $result; + } + + public function lastInsertId(): int|string + { + return $this->pdo()->lastInsertId(); + } + + public function beginTransaction(): void + { + $this->pdo()->beginTransaction(); + } + + public function commit(): void + { + $this->pdo()->commit(); + } + + public function rollBack(): void + { + $this->pdo()->rollBack(); + } + + public function getNativeConnection(): PDO + { + return $this->pdo(); + } + + public function getServerVersion(): string + { + return $this->pdo()->getAttribute(PDO::ATTR_SERVER_VERSION); + } +} diff --git a/packages/Tempest/src/Config/TempestConnectionModule.php b/packages/Tempest/src/Config/TempestConnectionModule.php new file mode 100644 index 000000000..4dad692ad --- /dev/null +++ b/packages/Tempest/src/Config/TempestConnectionModule.php @@ -0,0 +1,118 @@ +registerServiceDefinition( + $connection->getReferenceName(), + $this->buildConnectionFactoryDefinition($connection) + ); + } + + if (! class_exists(HeaderBasedMultiTenantConnectionFactory::class)) { + return; + } + + $multiTenantConfigurations = ExtensionObjectResolver::resolve(MultiTenantConfiguration::class, $extensionObjects); + + $tempestRelatedMultiTenantConfigurations = []; + + foreach ($multiTenantConfigurations as $multiTenantConfiguration) { + foreach ($multiTenantConfiguration->getTenantToConnectionMapping() as $connectionReference) { + if (! ($connectionReference instanceof TempestConnectionReference)) { + continue; + } + + $tempestRelatedMultiTenantConfigurations[$multiTenantConfiguration->getReferenceName()] = $multiTenantConfiguration; + + $messagingConfiguration->registerServiceDefinition( + $connectionReference->getReferenceName(), + $this->buildConnectionFactoryDefinition($connectionReference) + ); + } + } + + if ($tempestRelatedMultiTenantConfigurations === []) { + return; + } + + $messagingConfiguration->registerServiceDefinition( + TempestTenantDatabaseSwitcher::class, + new Definition( + TempestTenantDatabaseSwitcher::class, + [], + [ + TempestTenantDatabaseSwitcher::class, + 'create', + ] + ) + ); + + $messagingConfiguration->registerMessageHandler( + ServiceActivatorBuilder::create(TempestTenantDatabaseSwitcher::class, 'switchOn') + ->withInputChannelName(HeaderBasedMultiTenantConnectionFactory::TENANT_ACTIVATED_CHANNEL_NAME) + ); + + $messagingConfiguration->registerMessageHandler( + ServiceActivatorBuilder::create(TempestTenantDatabaseSwitcher::class, 'switchOff') + ->withInputChannelName(HeaderBasedMultiTenantConnectionFactory::TENANT_DEACTIVATED_CHANNEL_NAME) + ); + } + + public function canHandle($extensionObject): bool + { + return $extensionObject instanceof TempestConnectionReference + || $extensionObject instanceof MultiTenantConfiguration; + } + + public function getModulePackageName(): string + { + return ModulePackageList::TEMPEST_PACKAGE; + } + + private function buildConnectionFactoryDefinition(TempestConnectionReference $connection): Definition + { + return new Definition( + ConnectionFactory::class, + [ + $connection, + ], + [ + TempestConnectionResolver::class, + 'resolve', + ] + ); + } +} diff --git a/packages/Tempest/src/Config/TempestConnectionReference.php b/packages/Tempest/src/Config/TempestConnectionReference.php new file mode 100644 index 000000000..b1b3c0b80 --- /dev/null +++ b/packages/Tempest/src/Config/TempestConnectionReference.php @@ -0,0 +1,71 @@ +configTag; + } + + public function getDefinition(): Definition + { + return new Definition( + TempestConnectionReference::class, + [ + $this->getReferenceName(), + $this->configTag, + ], + [ + self::class, + 'fromTagAndReferenceName', + ] + ); + } + + public static function fromTagAndReferenceName(string $referenceName, ?string $configTag = null): self + { + return new self($referenceName, $configTag); + } +} diff --git a/packages/Tempest/src/Config/TempestConnectionResolver.php b/packages/Tempest/src/Config/TempestConnectionResolver.php new file mode 100644 index 000000000..cd8f274c4 --- /dev/null +++ b/packages/Tempest/src/Config/TempestConnectionResolver.php @@ -0,0 +1,97 @@ +getConfigTag(); + + if ($configTag === null) { + return self::resolveFromTempestConnection(); + } + + return self::resolveFromTaggedConfig($configTag); + } + + private static function resolveFromTaggedConfig(string $configTag): ConnectionFactory + { + $container = GenericContainer::instance(); + + if (! $container->has(TempestConnection::class, tag: $configTag)) { + $databaseConfig = $container->get(DatabaseConfig::class, tag: $configTag); + $connection = new PDOConnection($databaseConfig); + $connection->connect(); + $container->singleton(TempestConnection::class, $connection, tag: $configTag); + } + + return self::doctrineConnectionFromTempestConnection( + $container->get(TempestConnection::class, tag: $configTag), + $container->get(DatabaseConfig::class, tag: $configTag), + ); + } + + private static function resolveFromTempestConnection(): ConnectionFactory + { + $container = GenericContainer::instance(); + $databaseConfig = $container->get(DatabaseConfig::class); + $driver = self::driverForDialect($databaseConfig->dialect); + + // TempestDynamicDriver returns a TempestDynamicDriverConnection that re-resolves + // Tempest's default Connection singleton on each DBAL call. Combined with + // TempestTenantDatabaseSwitcher closing the Doctrine connection on switch, + // the DbalTransactionInterceptor transparently follows tenant connection promotions. + $doctrineConnection = new Connection([], new TempestDynamicDriver()); + + return DbalConnection::create($doctrineConnection); + } + + private static function doctrineConnectionFromTempestConnection( + TempestConnection $tempestConnection, + DatabaseConfig $databaseConfig, + ): ConnectionFactory { + $pdoProperty = new ReflectionProperty($tempestConnection, 'pdo'); + $sharedPdo = $pdoProperty->getValue($tempestConnection); + + $driver = self::driverForDialect($databaseConfig->dialect); + + $doctrineConnection = new Connection(['pdo' => $sharedPdo], $driver); + + return DbalConnection::create($doctrineConnection); + } + + private static function driverForDialect(DatabaseDialect $dialect): Driver + { + return match ($dialect) { + DatabaseDialect::POSTGRESQL => new PostgresDriver(), + DatabaseDialect::MYSQL => new MySqlDriver(), + DatabaseDialect::SQLITE => new SQLiteDriver(), + }; + } +} diff --git a/packages/Tempest/src/Config/TempestTenantDatabaseSwitcher.php b/packages/Tempest/src/Config/TempestTenantDatabaseSwitcher.php new file mode 100644 index 000000000..15a039fa7 --- /dev/null +++ b/packages/Tempest/src/Config/TempestTenantDatabaseSwitcher.php @@ -0,0 +1,89 @@ +get(DatabaseConfig::class); + + return new self($defaultConfig); + } + + public function switchOn(string|ConnectionReference $activatedConnection): void + { + if (! ($activatedConnection instanceof TempestConnectionReference)) { + return; + } + + $configTag = $activatedConnection->getConfigTag(); + + if ($configTag === null) { + return; + } + + $container = GenericContainer::instance(); + $tenantConfig = $container->get(DatabaseConfig::class, tag: $configTag); + + // Ensure the tagged Connection singleton is built so TempestConnectionResolver shares its PDO + if (! $container->has(Connection::class, tag: $configTag)) { + $connection = new PDOConnection($tenantConfig); + $connection->connect(); + $container->singleton(Connection::class, $connection, tag: $configTag); + } + + // Register the tenant's already-built Connection as the default so IsDatabaseModel + // and Ecotone's DBAL share one PDO — enabling transaction rollback across both + $tenantConnection = $container->get(Connection::class, tag: $configTag); + $container->singleton(DatabaseConfig::class, $tenantConfig); + $container->singleton(Connection::class, $tenantConnection); + $container->unregister(Database::class); + + // Close the Doctrine Connection so TempestDynamicDriver reconnects on next use, + // picking up the now-promoted default Connection's PDO + $this->closeDoctrineDefaultConnection($container); + } + + public function switchOff(): void + { + $container = GenericContainer::instance(); + $container->singleton(DatabaseConfig::class, $this->defaultDatabaseConfig); + $container->unregister(Connection::class); + $container->unregister(Database::class); + + $this->closeDoctrineDefaultConnection($container); + } + + private function closeDoctrineDefaultConnection(GenericContainer $container): void + { + if (! $container->has(DbalConnectionFactory::class)) { + return; + } + + try { + $factory = $container->get(DbalConnectionFactory::class); + $doctrineConnection = $factory->createContext()->getDbalConnection(); + $doctrineConnection->close(); + } catch (\Throwable) { + } + } +} diff --git a/packages/Tempest/src/ConsoleCommandProxyGenerator.php b/packages/Tempest/src/ConsoleCommandProxyGenerator.php new file mode 100644 index 000000000..c6f3b7a84 --- /dev/null +++ b/packages/Tempest/src/ConsoleCommandProxyGenerator.php @@ -0,0 +1,148 @@ +resolveExpectedFilePaths($commandConfigurations, $outputDirectory); + + if ($configHash !== null && $this->isHashUnchanged($outputDirectory, $configHash, $generatedFiles)) { + return $generatedFiles; + } + + $generatedFiles = []; + + foreach ($commandConfigurations as $configuration) { + $generatedFiles[] = $this->writeProxyFile($configuration, $outputDirectory); + } + + if ($configHash !== null) { + file_put_contents($outputDirectory . DIRECTORY_SEPARATOR . self::HASH_MARKER_FILE, $configHash); + } + + return $generatedFiles; + } + + private function resolveExpectedFilePaths(array $commandConfigurations, string $outputDirectory): array + { + $files = []; + foreach ($commandConfigurations as $configuration) { + $className = $this->buildClassName($configuration->getName()); + $files[] = $outputDirectory . DIRECTORY_SEPARATOR . $className . '.php'; + } + return $files; + } + + private function isHashUnchanged(string $outputDirectory, string $configHash, array $expectedFiles): bool + { + $markerFile = $outputDirectory . DIRECTORY_SEPARATOR . self::HASH_MARKER_FILE; + + if (! file_exists($markerFile)) { + return false; + } + + if (file_get_contents($markerFile) !== $configHash) { + return false; + } + + foreach ($expectedFiles as $file) { + if (! file_exists($file)) { + return false; + } + } + + return true; + } + + private function writeProxyFile(ConsoleCommandConfiguration $configuration, string $outputDirectory): string + { + $commandName = $configuration->getName(); + $className = $this->buildClassName($commandName); + $filePath = $outputDirectory . DIRECTORY_SEPARATOR . $className . '.php'; + + file_put_contents($filePath, $this->buildProxyClassCode($className, $commandName)); + + return $filePath; + } + + private function buildProxyClassCode(string $className, string $commandName): string + { + $escapedCommandName = addslashes($commandName); + + return <<messagingSystem->getGatewayByName(ConsoleCommandRunner::class); + \$allArgs = []; + foreach (\$this->argumentBag->all() as \$arg) { + \$allArgs[\$arg->name !== null ? \$arg->name : ''] = \$arg->value; + } + \$result = \$runner->execute('{$escapedCommandName}', \$allArgs); + if (\$result instanceof ConsoleCommandResultSet) { + \$this->console->writeln(implode(' | ', \$result->getColumnHeaders())); + foreach (\$result->getRows() as \$row) { + \$this->console->writeln(implode(' | ', \$row)); + } + } + return ExitCode::SUCCESS; + } + } + PHP; + } + + private function buildClassName(string $commandName): string + { + return 'EcotoneConsoleCommand_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $commandName); + } +} diff --git a/packages/Tempest/src/EcotoneCacheClearCommand.php b/packages/Tempest/src/EcotoneCacheClearCommand.php new file mode 100644 index 000000000..f5db91efb --- /dev/null +++ b/packages/Tempest/src/EcotoneCacheClearCommand.php @@ -0,0 +1,47 @@ +removeCacheDirectory($cacheDirectory); + + $this->console->writeln('Ecotone cache cleared.'); + } + + private function removeCacheDirectory(string $directory): void + { + if (! is_dir($directory)) { + return; + } + + foreach (glob($directory . DIRECTORY_SEPARATOR . '*') ?: [] as $item) { + if (is_dir($item)) { + $this->removeCacheDirectory($item); + } else { + @unlink($item); + } + } + + @rmdir($directory); + } +} diff --git a/packages/Tempest/src/EcotoneConfig.php b/packages/Tempest/src/EcotoneConfig.php new file mode 100644 index 000000000..3263ed486 --- /dev/null +++ b/packages/Tempest/src/EcotoneConfig.php @@ -0,0 +1,32 @@ +serviceName === '') { + $this->serviceName = (string) env('ECOTONE_SERVICE_NAME', ''); + } + if (! $this->cacheConfiguration) { + $this->cacheConfiguration = (bool) env('ECOTONE_CACHE_CONFIGURATION', false); + } + } +} diff --git a/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php b/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php new file mode 100644 index 000000000..29e368f07 --- /dev/null +++ b/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php @@ -0,0 +1,91 @@ +container->has(EcotoneConfig::class)) { + return; + } + + (new MessagingSystemInitializer())->initialize($this->container); + } + + $definitionHolder = MessagingSystemInitializer::getDefinitionHolder(); + + if ($definitionHolder === null) { + return; + } + + $commands = $definitionHolder->getRegisteredCommands(); + + if ($commands === []) { + return; + } + + $outputDirectory = MessagingSystemInitializer::getProxyDirectory() + ?? sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest_console_proxies'; + + $generator = new ConsoleCommandProxyGenerator(); + $generatedFiles = $generator->generate($commands, $outputDirectory, MessagingSystemInitializer::getConfigHash()); + + foreach ($generatedFiles as $file) { + require_once $file; + } + + foreach ($commands as $commandConfiguration) { + $className = 'Ecotone\\Tempest\\Generated\\' . $this->buildClassName($commandConfiguration->getName()); + + if (! class_exists($className)) { + continue; + } + + $classReflector = new ClassReflector($className); + + foreach ($classReflector->getPublicMethods() as $method) { + $consoleCommand = $method->getAttribute(ConsoleCommand::class); + + if ($consoleCommand === null) { + continue; + } + + $this->consoleConfig->addCommand($method, $consoleCommand); + } + } + } + + private function buildClassName(string $commandName): string + { + return 'EcotoneConsoleCommand_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $commandName); + } +} diff --git a/packages/Tempest/src/EcotoneServiceInitializer.php b/packages/Tempest/src/EcotoneServiceInitializer.php new file mode 100644 index 000000000..73ad34f11 --- /dev/null +++ b/packages/Tempest/src/EcotoneServiceInitializer.php @@ -0,0 +1,63 @@ +getName()]); + } + + if (self::$compiling) { + return false; + } + + $container = GenericContainer::instance(); + + if ($container === null) { + return false; + } + + self::$compiling = true; + $container->get(ConfiguredMessagingSystem::class); + self::$compiling = false; + + return isset(self::$compiledServiceIds[$class->getName()]); + } + + public function initialize(ClassReflector $class, null|string|UnitEnum $tag, Container $container): object + { + $configuredMessagingSystem = $container->get(ConfiguredMessagingSystem::class); + + return $configuredMessagingSystem->getGatewayByName($class->getName()); + } +} diff --git a/packages/Tempest/src/MessagingSystemInitializer.php b/packages/Tempest/src/MessagingSystemInitializer.php new file mode 100644 index 000000000..33d4dd38a --- /dev/null +++ b/packages/Tempest/src/MessagingSystemInitializer.php @@ -0,0 +1,271 @@ +resolveEcotoneConfig($container); + $rootPath = getcwd(); + $cacheDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest'; + $environment = getenv('APP_ENV') ?: 'production'; + $useProductionCache = in_array($environment, ['prod', 'production'], true) ? true : $config->cacheConfiguration; + + $applicationConfiguration = $this->buildServiceConfiguration($config, $environment, $cacheDirectory, $container); + + [$serviceCacheConfiguration, $definitionHolder, $configHash] = $this->prepareFromCache( + $useProductionCache, + $rootPath, + $applicationConfiguration, + $config->test, + $cacheDirectory, + ); + + self::$definitionHolder = $definitionHolder; + self::$configHash = $configHash; + self::$proxyDirectory = $cacheDirectory . DIRECTORY_SEPARATOR . 'console_proxies'; + + $ecotoneContainer = new LazyInMemoryContainer( + $definitionHolder->getDefinitions(), + new TempestPsrContainerAdapter($container), + ); + + $ecotoneContainer->set( + ConfigurationVariableService::REFERENCE_NAME, + new TempestConfigurationVariableService(), + ); + + $ecotoneContainer->set( + ServiceCacheConfiguration::REFERENCE_NAME, + $serviceCacheConfiguration, + ); + + $this->wireLogger($container, $ecotoneContainer); + + EcotoneServiceInitializer::markCompiled(array_keys($definitionHolder->getDefinitions())); + + return $ecotoneContainer->get(ConfiguredMessagingSystem::class); + } + + private function resolveEcotoneConfig(Container $container): EcotoneConfig + { + if ($container->has(EcotoneConfig::class)) { + return $container->get(EcotoneConfig::class); + } + + return new EcotoneConfig(); + } + + private function deriveNamespacesFromComposer(Container $container): array + { + try { + $composer = $container->get(Composer::class); + } catch (\Throwable) { + return []; + } + + $namespaces = []; + foreach ($composer->namespaces as $psr4Namespace) { + $namespaces[] = $psr4Namespace->namespace; + } + + return $namespaces; + } + + private function wireLogger(Container $container, LazyInMemoryContainer $ecotoneContainer): void + { + try { + $logger = $container->get(LoggerInterface::class); + $ecotoneContainer->set('logger', $logger); + $ecotoneContainer->set(LoggerInterface::class, $logger); + } catch (\Throwable) { + } + } + + private function buildServiceConfiguration( + EcotoneConfig $config, + string $environment, + string $cacheDirectory, + Container $container, + ): ServiceConfiguration { + $namespaces = $config->namespaces; + + if ($namespaces === [] && $config->loadAppNamespaces) { + $namespaces = $this->deriveNamespacesFromComposer($container); + } + + $applicationConfiguration = ServiceConfiguration::createWithDefaults() + ->withEnvironment($environment) + ->withLoadCatalog('') + ->withFailFast(false) + ->withNamespaces($namespaces) + ->withSkippedModulePackageNames($config->skippedModulePackageNames) + ->withCacheDirectoryPath($cacheDirectory); + + if ($config->serviceName !== '') { + $applicationConfiguration = $applicationConfiguration->withServiceName($config->serviceName); + } + + if ($config->defaultSerializationMediaType !== '') { + $applicationConfiguration = $applicationConfiguration->withDefaultSerializationMediaType($config->defaultSerializationMediaType); + } + + if ($config->defaultErrorChannel !== '') { + $applicationConfiguration = $applicationConfiguration->withDefaultErrorChannel($config->defaultErrorChannel); + } + + if ($config->licenceKey !== '') { + $applicationConfiguration = $applicationConfiguration->withLicenceKey($config->licenceKey); + } + + $applicationConfiguration = $applicationConfiguration->withExtensionObjects([new TempestRepositoryBuilder()]); + + return MessagingSystemConfiguration::addCorePackage($applicationConfiguration, $config->test); + } + + private function prepareFromCache( + bool $useProductionCache, + string $rootCatalog, + ServiceConfiguration $applicationConfiguration, + bool $enableTesting, + string $cacheDirectory, + ): array { + if ($useProductionCache && $cacheDirectory) { + $messagingFile = $cacheDirectory . DIRECTORY_SEPARATOR . self::MESSAGING_SYSTEM_FILE_NAME; + + if (file_exists($messagingFile)) { + $definitionHolder = unserialize(file_get_contents($messagingFile)); + + if ($definitionHolder instanceof ContainerDefinitionsHolder) { + $persistedHash = $this->readPersistedConfigHash($cacheDirectory); + + return [new ServiceCacheConfiguration($cacheDirectory, true), $definitionHolder, $persistedHash]; + } + } + } + + $annotationFinder = AnnotationFinderFactory::createForAttributes( + realpath($rootCatalog) ?: $rootCatalog, + $applicationConfiguration->getNamespaces(), + $applicationConfiguration->getEnvironment(), + $applicationConfiguration->getLoadedCatalog() ?? '', + MessagingSystemConfiguration::getModuleClassesFor($applicationConfiguration), + isRunningForTesting: $enableTesting, + ); + + $cacheHash = $annotationFinder->getCacheMessagingFileNameBasedOnConfig( + realpath($rootCatalog) ?: $rootCatalog, + $applicationConfiguration, + [], + $enableTesting, + ); + + $serviceCacheConfiguration = new ServiceCacheConfiguration( + $useProductionCache ? $cacheDirectory : ($cacheDirectory . DIRECTORY_SEPARATOR . $cacheHash), + true, + ); + + $definitionHolder = null; + $messagingSystemCachePath = $serviceCacheConfiguration->getPath() . DIRECTORY_SEPARATOR . self::MESSAGING_SYSTEM_FILE_NAME; + + if ($serviceCacheConfiguration->shouldUseCache() && file_exists($messagingSystemCachePath)) { + $definitionHolder = unserialize(file_get_contents($messagingSystemCachePath)); + } + + if (! $definitionHolder instanceof ContainerDefinitionsHolder) { + $configuration = MessagingSystemConfiguration::prepareWithAnnotationFinder( + $annotationFinder, + new TempestConfigurationVariableService(), + $applicationConfiguration, + enableTestPackage: $enableTesting, + ); + $definitionHolder = ContainerConfig::buildDefinitionHolder($configuration); + + if ($serviceCacheConfiguration->shouldUseCache()) { + MessagingSystemConfiguration::prepareCacheDirectory($serviceCacheConfiguration); + file_put_contents($messagingSystemCachePath, serialize($definitionHolder)); + + if ($useProductionCache && $cacheHash !== null) { + $this->persistConfigHash($cacheDirectory, $cacheHash); + } + } + } + + return [$serviceCacheConfiguration, $definitionHolder, $cacheHash]; + } + + private function persistConfigHash(string $cacheDirectory, string $configHash): void + { + $hashFile = $cacheDirectory . DIRECTORY_SEPARATOR . self::CONFIG_HASH_FILE_NAME; + file_put_contents($hashFile, $configHash); + } + + private function readPersistedConfigHash(string $cacheDirectory): ?string + { + $hashFile = $cacheDirectory . DIRECTORY_SEPARATOR . self::CONFIG_HASH_FILE_NAME; + + if (! file_exists($hashFile)) { + return null; + } + + $hash = file_get_contents($hashFile); + + return $hash !== false && $hash !== '' ? $hash : null; + } +} diff --git a/packages/Tempest/src/TempestConfigurationVariableService.php b/packages/Tempest/src/TempestConfigurationVariableService.php new file mode 100644 index 000000000..dce93e94c --- /dev/null +++ b/packages/Tempest/src/TempestConfigurationVariableService.php @@ -0,0 +1,25 @@ +container->get($id); + } + + public function has(string $id): bool + { + if ($this->container->has($id)) { + return true; + } + + if (! class_exists($id) && ! interface_exists($id)) { + return false; + } + + try { + $reflection = new ReflectionClass($id); + return $reflection->isInstantiable(); + } catch (ReflectionException) { + return false; + } + } +} diff --git a/packages/Tempest/src/TempestRepository.php b/packages/Tempest/src/TempestRepository.php new file mode 100644 index 000000000..4f8fe6744 --- /dev/null +++ b/packages/Tempest/src/TempestRepository.php @@ -0,0 +1,67 @@ +classUsesTrait($aggregateClassName, IsDatabaseModel::class); + } + + public function findBy(string $aggregateClassName, array $identifiers): ?object + { + return $aggregateClassName::findById(array_pop($identifiers)); + } + + public function save(array $identifiers, object $aggregate, array $metadata, ?int $versionBeforeHandling): void + { + $aggregate->save(); + } + + private function classUsesTrait(string $className, string $traitName): bool + { + foreach ($this->collectAllTraits($className) as $usedTrait) { + if ($usedTrait === $traitName) { + return true; + } + } + + return false; + } + + private function collectAllTraits(string $className): array + { + $traits = []; + + $class = $className; + while ($class !== false) { + $traits = array_merge($traits, $this->collectTraitsRecursively(class_uses($class) ?: [])); + $class = get_parent_class($class); + } + + return $traits; + } + + private function collectTraitsRecursively(array $traits): array + { + $collected = array_values($traits); + + foreach ($traits as $trait) { + $nestedTraits = class_uses($trait) ?: []; + if ($nestedTraits !== []) { + $collected = array_merge($collected, $this->collectTraitsRecursively($nestedTraits)); + } + } + + return $collected; + } +} diff --git a/packages/Tempest/src/TempestRepositoryBuilder.php b/packages/Tempest/src/TempestRepositoryBuilder.php new file mode 100644 index 000000000..3a151a072 --- /dev/null +++ b/packages/Tempest/src/TempestRepositoryBuilder.php @@ -0,0 +1,33 @@ +tempestRepository = new TempestRepository(); + } + + public function canHandle(string $aggregateClassName): bool + { + return $this->tempestRepository->canHandle($aggregateClassName); + } + + public function compile(MessagingContainerBuilder $builder): Definition|Reference + { + return new Definition(TempestRepository::class); + } +} diff --git a/packages/Tempest/tests/Application/AppNamespaceAutoDiscoveryTest.php b/packages/Tempest/tests/Application/AppNamespaceAutoDiscoveryTest.php new file mode 100644 index 000000000..512e2bbf8 --- /dev/null +++ b/packages/Tempest/tests/Application/AppNamespaceAutoDiscoveryTest.php @@ -0,0 +1,119 @@ +internalStorage = '/tmp/ecotone_tempest_auto_ns_' . getmypid(); + $this->setupKernel(); + } + + public function setupKernel(): self + { + if ($this->root === '') { + $this->root = TempestTestPaths::appRoot(); + } + + EcotoneServiceInitializer::clearCache(); + + $appSrcLocation = new DiscoveryLocation('App\\Tempest\\', TempestTestPaths::appRoot() . '/src'); + + $allLocations = [ + new DiscoveryLocation('Ecotone\\Tempest\\', TempestTestPaths::srcPath()), + $appSrcLocation, + ]; + + $kernel = new FrameworkKernel( + root: $this->root, + discoveryLocations: $allLocations, + internalStorage: $this->internalStorage, + ); + + $kernel->registerKernel() + ->validateRoot() + ->loadEnv() + ->registerEmergencyExceptionHandler() + ->registerShutdownFunction() + ->registerInternalStorage() + ->loadComposer(); + + $this->injectDiscoveryConfig($kernel, $allLocations); + + $kernel->container->config(new EcotoneConfig( + skippedModulePackageNames: ModulePackageList::allPackages(), + test: true, + )); + + $kernel->loadConfig() + ->bootDiscovery() + ->registerExceptionHandler() + ->event(KernelEvent::BOOTED); + + $this->kernel = $kernel; + $this->container = $kernel->container; + + return $this; + } + + protected function tearDown(): void + { + parent::tearDown(); + restore_exception_handler(); + restore_error_handler(); + EcotoneServiceInitializer::clearCache(); + MessagingSystemInitializer::clearDefinitionHolder(); + } + + private function injectDiscoveryConfig(FrameworkKernel $kernel, array $extraLocations): void + { + $testAppComposer = (new Composer($this->root))->load(); + $testAppComposer->namespaces = []; + + $autoloadLocations = (new AutoloadDiscoveryLocations( + rootPath: TempestTestPaths::discoveryRoot(), + composer: $testAppComposer, + ))(); + + $discoveryConfig = $kernel->container->get(DiscoveryConfig::class); + $discoveryConfig->locations = [...$extraLocations, ...$autoloadLocations]; + + $kernel->container->config($discoveryConfig); + $kernel->discoveryConfig = $discoveryConfig; + } + + public function test_handlers_in_app_namespace_discovered_without_explicit_namespaces_config(): void + { + $commandBus = $this->container->get(CommandBus::class); + $queryBus = $this->container->get(QueryBus::class); + + $commandBus->sendWithRouting('app.ping'); + + $this->assertTrue($queryBus->sendWithRouting('app.wasHandled')); + } +} diff --git a/packages/Tempest/tests/Application/BusinessInterfaceResolutionTest.php b/packages/Tempest/tests/Application/BusinessInterfaceResolutionTest.php new file mode 100644 index 000000000..378dde598 --- /dev/null +++ b/packages/Tempest/tests/Application/BusinessInterfaceResolutionTest.php @@ -0,0 +1,43 @@ +container->get(CounterGateway::class); + + $this->assertInstanceOf(CounterGateway::class, $counterGateway); + } + + public function test_custom_business_interface_forwards_to_handler_when_resolved_first(): void + { + $counterGateway = $this->container->get(CounterGateway::class); + + $commandBus = $this->container->get(\Ecotone\Modelling\CommandBus::class); + $commandBus->sendWithRouting('counter.increment'); + + $this->assertSame(1, $counterGateway->get()); + } +} diff --git a/packages/Tempest/tests/Application/CacheClearCommandTest.php b/packages/Tempest/tests/Application/CacheClearCommandTest.php new file mode 100644 index 000000000..3fc3d03d1 --- /dev/null +++ b/packages/Tempest/tests/Application/CacheClearCommandTest.php @@ -0,0 +1,58 @@ +assertTrue(file_exists($messagingFile)); + + $this->console + ->call('ecotone:cache:clear') + ->assertSuccess(); + + $this->assertFalse(file_exists($messagingFile)); + } + + public function test_cache_clear_command_removes_proxy_files(): void + { + $proxyDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest' . DIRECTORY_SEPARATOR . 'console_proxies'; + @mkdir($proxyDir, 0777, true); + $proxyFile = $proxyDir . DIRECTORY_SEPARATOR . 'SomeProxy.php'; + file_put_contents($proxyFile, 'assertTrue(file_exists($proxyFile)); + + $this->console + ->call('ecotone:cache:clear') + ->assertSuccess(); + + $this->assertFalse(file_exists($proxyFile)); + } +} diff --git a/packages/Tempest/tests/Application/ConsoleCommandEndToEndTest.php b/packages/Tempest/tests/Application/ConsoleCommandEndToEndTest.php new file mode 100644 index 000000000..099d72dc8 --- /dev/null +++ b/packages/Tempest/tests/Application/ConsoleCommandEndToEndTest.php @@ -0,0 +1,40 @@ +console + ->call('ecotone:list') + ->assertSuccess() + ->assertContains('ecotone_test_queue'); + } + + public function test_ecotone_run_command_runs_async_consumer_through_tempest_console_runner_by_name(): void + { + $this->console + ->call('ecotone:run', ['consumerName' => 'ecotone_test_queue', 'finishWhenNoMessages' => 'true']) + ->assertSuccess(); + } +} diff --git a/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php b/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php new file mode 100644 index 000000000..d4c1a0064 --- /dev/null +++ b/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php @@ -0,0 +1,162 @@ +generate( + [ + ConsoleCommandConfiguration::create( + 'ecotone.channel.ecotone:list', + 'ecotone:list', + [], + ), + ], + $outputDir, + ); + + $this->assertCount(1, $generatedClasses); + $this->assertFileExists($generatedClasses[0]); + + $fileContent = file_get_contents($generatedClasses[0]); + $this->assertStringContainsString("name: 'ecotone:list'", $fileContent); + + array_map('unlink', $generatedClasses); + @rmdir($outputDir); + } + + public function test_ecotone_commands_are_registered_in_tempest_console_config(): void + { + $consoleConfig = $this->container->get(\Tempest\Console\ConsoleConfig::class); + + $this->assertArrayHasKey('ecotone:list', $consoleConfig->commands); + $this->assertArrayHasKey('ecotone:run', $consoleConfig->commands); + } + + public function test_generated_proxy_invoke_forwards_to_ecotone_console_command_runner_and_returns_exit_success(): void + { + $proxyClassName = 'Ecotone\\Tempest\\Generated\\EcotoneConsoleCommand_ecotone_list'; + + $this->assertTrue(class_exists($proxyClassName)); + + $proxy = $this->container->get($proxyClassName); + + $result = $proxy->__invoke(); + + $this->assertSame(\Tempest\Console\ExitCode::SUCCESS, $result); + } + + public function test_proxy_files_are_not_rewritten_when_config_hash_is_unchanged(): void + { + $outputDir = sys_get_temp_dir() . '/ecotone_proxy_hash_test_' . getmypid(); + $commands = [ + ConsoleCommandConfiguration::create('ecotone.channel.ecotone:list', 'ecotone:list', []), + ]; + $configHash = 'abc123'; + + $generator = new ConsoleCommandProxyGenerator(); + $firstFiles = $generator->generate($commands, $outputDir, $configHash); + + $mtimeBefore = filemtime($firstFiles[0]); + + sleep(1); + + $secondFiles = $generator->generate($commands, $outputDir, $configHash); + + $mtimeAfter = filemtime($secondFiles[0]); + + $this->assertSame($mtimeBefore, $mtimeAfter); + + foreach ($firstFiles as $file) { + @unlink($file); + } + @unlink($outputDir . '/.ecotone_hash'); + @rmdir($outputDir); + } + + public function test_ecotone_list_command_runs_through_tempest_console_runner_by_name_and_prints_column_headers(): void + { + $this->console + ->call('ecotone:list') + ->assertSuccess() + ->assertContains('Name'); + } + + public function test_generator_produces_proxy_code_that_prints_console_command_result_set(): void + { + $outputDir = sys_get_temp_dir() . '/ecotone_proxy_result_test_' . getmypid(); + + $generator = new ConsoleCommandProxyGenerator(); + $generatedClasses = $generator->generate( + [ + ConsoleCommandConfiguration::create( + 'ecotone.channel.ecotone:list', + 'ecotone:list', + [], + ), + ], + $outputDir, + ); + + $fileContent = file_get_contents($generatedClasses[0]); + $this->assertStringContainsString('Console $console', $fileContent); + $this->assertStringContainsString('ConsoleCommandResultSet', $fileContent); + $this->assertStringContainsString('writeln', $fileContent); + + array_map('unlink', $generatedClasses); + @rmdir($outputDir); + } + + public function test_proxy_files_are_rewritten_when_config_hash_changes(): void + { + $outputDir = sys_get_temp_dir() . '/ecotone_proxy_rehash_test_' . getmypid(); + $commands = [ + ConsoleCommandConfiguration::create('ecotone.channel.ecotone:list', 'ecotone:list', []), + ]; + + $generator = new ConsoleCommandProxyGenerator(); + $firstFiles = $generator->generate($commands, $outputDir, 'hash_v1'); + + $mtimeBefore = filemtime($firstFiles[0]); + + sleep(1); + + $secondFiles = $generator->generate($commands, $outputDir, 'hash_v2'); + + $mtimeAfter = filemtime($secondFiles[0]); + + $this->assertGreaterThan($mtimeBefore, $mtimeAfter); + + foreach ($secondFiles as $file) { + @unlink($file); + } + @unlink($outputDir . '/.ecotone_hash'); + @rmdir($outputDir); + } +} diff --git a/packages/Tempest/tests/Application/CoverageParityTest.php b/packages/Tempest/tests/Application/CoverageParityTest.php new file mode 100644 index 000000000..8c08e7a9f --- /dev/null +++ b/packages/Tempest/tests/Application/CoverageParityTest.php @@ -0,0 +1,101 @@ +clearEcotoneTempestCacheDir(); + parent::setUp(); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->clearEcotoneTempestCacheDir(); + } + + public function test_handlers_work_on_warm_prod_cache_boot(): void + { + $userId = 'warm-cache-user'; + + $commandBus = $this->container->get(CommandBus::class); + $commandBus->sendWithRouting('user.register', $userId); + + $userRepository = $this->container->get(UserRepository::class); + + $this->assertEquals( + User::register($userId), + $userRepository->getUser($userId), + ); + + EcotoneServiceInitializer::clearCache(); + MessagingSystemInitializer::clearDefinitionHolder(); + + $this->setupKernel(); + + $commandBus = $this->container->get(CommandBus::class); + $commandBus->sendWithRouting('user.register', $userId); + + $userRepository = $this->container->get(UserRepository::class); + + $this->assertEquals( + User::register($userId), + $userRepository->getUser($userId), + ); + } + + public function test_default_error_channel_configured_in_ecotone_config_does_not_break_boot(): void + { + $this->assertInstanceOf( + CommandBus::class, + $this->container->get(CommandBus::class), + ); + } + + private function clearEcotoneTempestCacheDir(): void + { + $dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest'; + if (! is_dir($dir)) { + return; + } + foreach (glob($dir . '/*') ?: [] as $file) { + is_dir($file) ? $this->removeDirectory($file) : @unlink($file); + } + @rmdir($dir); + } + + private function removeDirectory(string $dir): void + { + foreach (glob($dir . '/*') ?: [] as $file) { + is_dir($file) ? $this->removeDirectory($file) : @unlink($file); + } + @rmdir($dir); + } +} diff --git a/packages/Tempest/tests/Application/LicenceKeyTest.php b/packages/Tempest/tests/Application/LicenceKeyTest.php new file mode 100644 index 000000000..95d530c66 --- /dev/null +++ b/packages/Tempest/tests/Application/LicenceKeyTest.php @@ -0,0 +1,36 @@ +assertInstanceOf( + CommandBus::class, + $this->container->get(CommandBus::class), + ); + } +} diff --git a/packages/Tempest/tests/Application/LoggerWiringTest.php b/packages/Tempest/tests/Application/LoggerWiringTest.php new file mode 100644 index 000000000..31da785d8 --- /dev/null +++ b/packages/Tempest/tests/Application/LoggerWiringTest.php @@ -0,0 +1,149 @@ +internalStorage = '/tmp/ecotone_tempest_logger_test_' . getmypid(); + $this->logHandler = new TestHandler(); + $this->setupKernel(); + } + + public function setupKernel(): self + { + if ($this->root === '') { + $this->root = TempestTestPaths::appRoot(); + } + + EcotoneServiceInitializer::clearCache(); + + $allLocations = [ + new DiscoveryLocation('Ecotone\\Tempest\\', TempestTestPaths::srcPath()), + new DiscoveryLocation('Test\\Ecotone\\Tempest\\Fixture\\', TempestTestPaths::fixturePath()), + ]; + + $kernel = new FrameworkKernel( + root: $this->root, + discoveryLocations: $allLocations, + internalStorage: $this->internalStorage, + ); + + $kernel->registerKernel() + ->validateRoot() + ->loadEnv() + ->registerEmergencyExceptionHandler() + ->registerShutdownFunction() + ->registerInternalStorage() + ->loadComposer(); + + $this->injectDiscoveryConfig($kernel, $allLocations); + + $kernel->container->config(new EcotoneConfig( + namespaces: ['Test\\Ecotone\\Tempest\\Fixture\\ExpressionLanguage\\'], + skippedModulePackageNames: ModulePackageList::allPackages(), + test: true, + )); + + $captureHandler = $this->logHandler; + $captureChannel = new class ($captureHandler) implements LogChannel { + public function __construct(private TestHandler $handler) + { + } + + public function getHandlers(Level $level): array + { + return [$this->handler]; + } + + public function getProcessors(): array + { + return []; + } + }; + + $kernel->loadConfig(); + + $kernel->container->config(new MultipleChannelsLogConfig( + channels: [$captureChannel], + prefix: 'test', + )); + + $kernel->bootDiscovery() + ->registerExceptionHandler() + ->event(KernelEvent::BOOTED); + + $this->kernel = $kernel; + $this->container = $kernel->container; + + return $this; + } + + protected function tearDown(): void + { + parent::tearDown(); + restore_exception_handler(); + restore_error_handler(); + EcotoneServiceInitializer::clearCache(); + MessagingSystemInitializer::clearDefinitionHolder(); + } + + private function injectDiscoveryConfig(FrameworkKernel $kernel, array $extraLocations): void + { + $testAppComposer = (new Composer($this->root))->load(); + $testAppComposer->namespaces = []; + + $autoloadLocations = (new AutoloadDiscoveryLocations( + rootPath: TempestTestPaths::discoveryRoot(), + composer: $testAppComposer, + ))(); + + $discoveryConfig = $kernel->container->get(DiscoveryConfig::class); + $discoveryConfig->locations = [...$extraLocations, ...$autoloadLocations]; + + $kernel->container->config($discoveryConfig); + $kernel->discoveryConfig = $discoveryConfig; + } + + public function test_ecotone_logs_flow_through_tempest_logger_after_wiring(): void + { + $queryBus = $this->container->get(QueryBus::class); + + $queryBus->sendWithRouting('getAmount'); + + $this->assertTrue( + $this->logHandler->hasInfoThatContains('Executing Query Handler'), + 'Expected Ecotone to emit log through the Tempest logger', + ); + } +} diff --git a/packages/Tempest/tests/Application/ParameterExpressionTest.php b/packages/Tempest/tests/Application/ParameterExpressionTest.php new file mode 100644 index 000000000..fadcdff72 --- /dev/null +++ b/packages/Tempest/tests/Application/ParameterExpressionTest.php @@ -0,0 +1,55 @@ +container->get(CommandBus::class); + $queryBus = $this->container->get(QueryBus::class); + + $commandBus->sendWithRouting('calculator.multiply', ['value' => 5]); + + $this->assertSame(50, $queryBus->sendWithRouting('calculator.getResult')); + + putenv('ECOTONE_MULTIPLIER'); + } + + public function test_parameter_function_with_env_variable_in_expression(): void + { + putenv('ECOTONE_ENV_MULTIPLIER=7'); + + $commandBus = $this->container->get(CommandBus::class); + $queryBus = $this->container->get(QueryBus::class); + + $commandBus->sendWithRouting('calculator.multiplyWithEnv', ['value' => 3]); + + $this->assertSame(21, $queryBus->sendWithRouting('calculator.getEnvResult')); + + putenv('ECOTONE_ENV_MULTIPLIER'); + } +} diff --git a/packages/Tempest/tests/Application/ProdCacheHashTest.php b/packages/Tempest/tests/Application/ProdCacheHashTest.php new file mode 100644 index 000000000..65781f9ed --- /dev/null +++ b/packages/Tempest/tests/Application/ProdCacheHashTest.php @@ -0,0 +1,83 @@ +clearEcotoneTempestCacheDir(); + parent::setUp(); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->clearEcotoneTempestCacheDir(); + } + + public function test_warm_prod_cache_path_returns_stable_non_null_config_hash(): void + { + $firstHash = MessagingSystemInitializer::getConfigHash(); + + $this->assertNotNull( + $firstHash, + 'Config hash must be non-null on first cold boot', + ); + + EcotoneServiceInitializer::clearCache(); + MessagingSystemInitializer::clearDefinitionHolder(); + + $this->setupKernel(); + + $secondHash = MessagingSystemInitializer::getConfigHash(); + + $this->assertSame( + $firstHash, + $secondHash, + 'Warm-cache boot must return the same config hash as the cold boot', + ); + } + + private function clearEcotoneTempestCacheDir(): void + { + $dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest'; + if (! is_dir($dir)) { + return; + } + foreach (glob($dir . '/*') ?: [] as $file) { + is_dir($file) ? $this->removeDirectory($file) : @unlink($file); + } + @rmdir($dir); + } + + private function removeDirectory(string $dir): void + { + foreach (glob($dir . '/*') ?: [] as $file) { + is_dir($file) ? $this->removeDirectory($file) : @unlink($file); + } + @rmdir($dir); + } +} diff --git a/packages/Tempest/tests/Application/RealBootTest.php b/packages/Tempest/tests/Application/RealBootTest.php new file mode 100644 index 000000000..3f1b47bcb --- /dev/null +++ b/packages/Tempest/tests/Application/RealBootTest.php @@ -0,0 +1,152 @@ +registerKernel() + ->validateRoot() + ->loadEnv() + ->registerEmergencyExceptionHandler() + ->registerShutdownFunction() + ->registerInternalStorage() + ->loadComposer(); + + $this->injectDiscoveryConfig($kernel, $ecotoneLocation, $appLocation); + + // Monorepo: all packages including Enterprise ones are present, so skip them to avoid + // licence errors. In a real app only installed packages load — no skip needed. + // The key proof: no ecotone.config.php discovered in the boot path; the app + // derives its EcotoneConfig from MessagingSystemInitializer's fallback new EcotoneConfig(). + $kernel->container->singleton( + EcotoneConfig::class, + new EcotoneConfig( + skippedModulePackageNames: ModulePackageList::allPackages(), + test: true, + ), + ); + + $kernel->loadConfig() + ->bootDiscovery() + ->registerExceptionHandler() + ->event(KernelEvent::BOOTED); + + // Resolve CommandBus WITHOUT touching ConfiguredMessagingSystem first — + // EcotoneServiceInitializer must trigger compile on the first gateway request. + $commandBus = $kernel->container->get(CommandBus::class); + $queryBus = $kernel->container->get(QueryBus::class); + + $commandBus->sendWithRouting('app.ping'); + + $this->assertTrue($queryBus->sendWithRouting('app.wasHandled')); + } + + public function test_zero_config_namespace_derivation_from_composer_psr4_discovers_handlers(): void + { + $internalStorage = '/tmp/ecotone_tempest_real_boot_' . getmypid(); + + $appLocation = new DiscoveryLocation('App\\Tempest\\', TempestTestPaths::appRoot() . '/src'); + $ecotoneLocation = new DiscoveryLocation('Ecotone\\Tempest\\', TempestTestPaths::srcPath()); + + $kernel = new FrameworkKernel( + root: TempestTestPaths::appRoot(), + discoveryLocations: [$ecotoneLocation, $appLocation], + internalStorage: $internalStorage, + ); + + $kernel->registerKernel() + ->validateRoot() + ->loadEnv() + ->registerEmergencyExceptionHandler() + ->registerShutdownFunction() + ->registerInternalStorage() + ->loadComposer(); + + $this->injectDiscoveryConfig($kernel, $ecotoneLocation, $appLocation); + + $kernel->container->config(new EcotoneConfig( + skippedModulePackageNames: ModulePackageList::allPackages(), + test: true, + )); + + $kernel->loadConfig() + ->bootDiscovery() + ->registerExceptionHandler() + ->event(KernelEvent::BOOTED); + + $commandBus = $kernel->container->get(CommandBus::class); + $queryBus = $kernel->container->get(QueryBus::class); + + $commandBus->sendWithRouting('app.ping'); + + $this->assertTrue($queryBus->sendWithRouting('app.wasHandled')); + } + + private function injectDiscoveryConfig( + FrameworkKernel $kernel, + DiscoveryLocation $ecotoneLocation, + DiscoveryLocation $appLocation, + ): void { + $vendorOnlyComposer = (new Composer(TempestTestPaths::appRoot()))->load(); + $vendorOnlyComposer->namespaces = []; + + $vendorLocations = (new AutoloadDiscoveryLocations( + rootPath: TempestTestPaths::discoveryRoot(), + composer: $vendorOnlyComposer, + ))(); + + $discoveryConfig = $kernel->container->get(DiscoveryConfig::class); + $discoveryConfig->locations = [$ecotoneLocation, $appLocation, ...$vendorLocations]; + + $kernel->container->config($discoveryConfig); + $kernel->discoveryConfig = $discoveryConfig; + } +} diff --git a/packages/Tempest/tests/Application/StaticStateIsolationTest.php b/packages/Tempest/tests/Application/StaticStateIsolationTest.php new file mode 100644 index 000000000..a23ff8716 --- /dev/null +++ b/packages/Tempest/tests/Application/StaticStateIsolationTest.php @@ -0,0 +1,46 @@ +container->get(CommandBus::class); + $commandBus->sendWithRouting('user.register', $userId); + + $userRepository = $this->container->get(UserRepository::class); + $this->assertEquals(User::register($userId), $userRepository->getUser($userId)); + + restore_exception_handler(); + restore_error_handler(); + + EcotoneServiceInitializer::clearCache(); + MessagingSystemInitializer::clearDefinitionHolder(); + + $this->setupKernel(); + + $secondCommandBus = $this->container->get(CommandBus::class); + $secondUserRepository = $this->container->get(UserRepository::class); + + $secondUserId = 'user-second-boot'; + $secondCommandBus->sendWithRouting('user.register', $secondUserId); + + $this->assertEquals(User::register($secondUserId), $secondUserRepository->getUser($secondUserId)); + } +} diff --git a/packages/Tempest/tests/Application/TempestApplicationTest.php b/packages/Tempest/tests/Application/TempestApplicationTest.php new file mode 100644 index 000000000..3b245651c --- /dev/null +++ b/packages/Tempest/tests/Application/TempestApplicationTest.php @@ -0,0 +1,55 @@ +assertInstanceOf( + CommandBus::class, + $this->container->get(CommandBus::class), + ); + } + + public function test_gateways_injectable_and_command_handler_flow_runs_end_to_end(): void + { + $userId = '123'; + + $commandBus = $this->container->get(CommandBus::class); + $commandBus->sendWithRouting('user.register', $userId); + + $userRepository = $this->container->get(UserRepository::class); + + $this->assertEquals( + User::register($userId), + $userRepository->getUser($userId), + ); + } + + public function test_expression_language_in_payload(): void + { + $commandBus = $this->container->get(CommandBus::class); + $queryBus = $this->container->get(QueryBus::class); + + $amount = 123; + $commandBus->sendWithRouting('setAmount', ['amount' => $amount]); + + $this->assertEquals( + $amount, + $queryBus->sendWithRouting('getAmount'), + ); + } +} diff --git a/packages/Tempest/tests/Dbal/ConnectionReferenceCredentialsTest.php b/packages/Tempest/tests/Dbal/ConnectionReferenceCredentialsTest.php new file mode 100644 index 000000000..802ea6294 --- /dev/null +++ b/packages/Tempest/tests/Dbal/ConnectionReferenceCredentialsTest.php @@ -0,0 +1,66 @@ +assertSame('tenant_a', $reference->getConfigTag()); + $this->assertSame('tenant_a', $reference->getReferenceName()); + } + + public function test_create_with_custom_reference_name(): void + { + $reference = TempestConnectionReference::create('tenant_a', 'custom_ref'); + + $this->assertSame('tenant_a', $reference->getConfigTag()); + $this->assertSame('custom_ref', $reference->getReferenceName()); + } + + public function test_get_definition_serializes_only_tag_name_no_credentials(): void + { + $reference = TempestConnectionReference::create('tenant_a'); + $definition = $reference->getDefinition(); + + $args = $definition->getArguments(); + $this->assertSame('tenant_a', $args[0]); + $this->assertSame('tenant_a', $args[1]); + foreach ($args as $arg) { + $this->assertIsString($arg); + } + } + + public function test_definition_round_trips_via_factory(): void + { + $reference = TempestConnectionReference::create('tenant_a'); + $definition = $reference->getDefinition(); + + $reconstructed = TempestConnectionReference::fromTagAndReferenceName( + ...$definition->getArguments() + ); + + $this->assertSame('tenant_a', $reconstructed->getConfigTag()); + $this->assertSame('tenant_a', $reconstructed->getReferenceName()); + } + + public function test_default_connection_has_no_tag(): void + { + $reference = TempestConnectionReference::defaultConnection(); + + $this->assertNull($reference->getConfigTag()); + $this->assertSame(DbalConnectionFactory::class, $reference->getReferenceName()); + } +} diff --git a/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php b/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php new file mode 100644 index 000000000..51ee738e0 --- /dev/null +++ b/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php @@ -0,0 +1,46 @@ +setupKernel(); + $this->container->config($postgresConfig); + + $messagingSystem = $this->container->get(ConfiguredMessagingSystem::class); + $connectionFactory = $messagingSystem->getServiceFromContainer(DbalConnectionFactory::class); + + $result = $connectionFactory->createContext()->getDbalConnection()->executeQuery('SELECT 1 AS result')->fetchOne(); + + $this->assertEquals(1, $result); + } +} diff --git a/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithConnectionTest.php b/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithConnectionTest.php new file mode 100644 index 000000000..b735739b4 --- /dev/null +++ b/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithConnectionTest.php @@ -0,0 +1,35 @@ +setupKernel(); + } +} diff --git a/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithoutConnectionTest.php b/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithoutConnectionTest.php new file mode 100644 index 000000000..417b3383c --- /dev/null +++ b/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithoutConnectionTest.php @@ -0,0 +1,33 @@ +expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches("/Dbal module requires 'Enqueue\\\\Dbal\\\\DbalConnectionFactory' to be configured/"); + + EcotoneLite::bootstrap( + classesToResolve: [], + configuration: ServiceConfiguration::createWithDefaults() + ->withEnvironment('prod') + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withNamespaces(['Test\\Ecotone\\Tempest\\Fixture\\Counter\\']), + pathToRootCatalog: __DIR__ . '/../../', + ); + } +} diff --git a/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php b/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php new file mode 100644 index 000000000..3d5439696 --- /dev/null +++ b/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php @@ -0,0 +1,84 @@ +setupKernel(); + + $this->container->config(TempestDatabaseConfigFactory::primary()); + + $database = $this->container->get(Database::class); + $database->execute(new Query('DROP TABLE IF EXISTS shared_connection_items')); + $createSql = (new CreateTableStatement('shared_connection_items')) + ->primary('id') + ->string('name') + ->compile($database->dialect); + $database->execute(new Query($createSql)); + } + + protected function tearDown(): void + { + try { + $database = $this->container->get(Database::class); + $database->execute(new Query('DROP TABLE IF EXISTS shared_connection_items')); + } catch (Throwable) { + } + parent::tearDown(); + } + + public function test_tempest_model_insert_is_rolled_back_when_ecotone_transaction_fails(): void + { + $commandBus = $this->container->get(CommandBus::class); + + try { + $commandBus->sendWithRouting('shared_connection.insert_then_fail'); + } catch (RuntimeException) { + } + + $count = $this->container->get(DbalConnectionFactory::class) + ->createContext() + ->getDbalConnection() + ->executeQuery('SELECT COUNT(*) FROM shared_connection_items') + ->fetchOne(); + + $this->assertSame(0, (int) $count, 'Tempest model insert must be rolled back by Ecotone\'s DBAL transaction'); + } +} diff --git a/packages/Tempest/tests/EcotoneIntegrationTestCase.php b/packages/Tempest/tests/EcotoneIntegrationTestCase.php new file mode 100644 index 000000000..e1841d222 --- /dev/null +++ b/packages/Tempest/tests/EcotoneIntegrationTestCase.php @@ -0,0 +1,128 @@ +root === '') { + $this->root = TempestTestPaths::appRoot(); + } + + $this->internalStorage = '/tmp/ecotone_tempest_test_storage_' . getmypid(); + + $allLocations = [...$this->discoveryLocations, ...$this->discoverTestLocations()]; + + $kernel = new FrameworkKernel( + root: $this->root, + discoveryLocations: $allLocations, + internalStorage: $this->internalStorage, + ); + + $kernel->registerKernel() + ->validateRoot() + ->loadEnv() + ->registerEmergencyExceptionHandler() + ->registerShutdownFunction() + ->registerInternalStorage() + ->loadComposer(); + + $this->injectDiscoveryAndConfig($kernel, $allLocations); + + $kernel->container->config($this->ecotoneConfig()); + + $kernel->loadConfig() + ->bootDiscovery() + ->registerExceptionHandler() + ->event(KernelEvent::BOOTED); + + $this->kernel = $kernel; + $this->container = $kernel->container; + + return $this; + } + + protected function tearDown(): void + { + parent::tearDown(); + restore_exception_handler(); + restore_error_handler(); + EcotoneServiceInitializer::clearCache(); + MessagingSystemInitializer::clearDefinitionHolder(); + $this->removeProxyCache(); + } + + private function removeProxyCache(): void + { + $proxyDirs = [ + MessagingSystemInitializer::getProxyDirectory(), + sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest_console_proxies', + ]; + + foreach (array_filter($proxyDirs) as $proxyDir) { + if (is_dir($proxyDir)) { + foreach (glob($proxyDir . '/*.php') ?: [] as $file) { + @unlink($file); + } + @unlink($proxyDir . '/.ecotone_hash'); + @rmdir($proxyDir); + } + } + } + + private function injectDiscoveryAndConfig(FrameworkKernel $kernel, array $extraLocations): void + { + $testAppComposer = (new Composer($this->root))->load(); + $testAppComposer->namespaces = []; + + $autoloadLocations = (new AutoloadDiscoveryLocations( + rootPath: TempestTestPaths::discoveryRoot(), + composer: $testAppComposer, + ))(); + + $discoveryConfig = $kernel->container->get(DiscoveryConfig::class); + $discoveryConfig->locations = [...$extraLocations, ...$autoloadLocations]; + + $kernel->container->config($discoveryConfig); + $kernel->discoveryConfig = $discoveryConfig; + } +} diff --git a/packages/Tempest/tests/Fixture/AsyncQueue/AsyncQueueChannelConfiguration.php b/packages/Tempest/tests/Fixture/AsyncQueue/AsyncQueueChannelConfiguration.php new file mode 100644 index 000000000..250188a71 --- /dev/null +++ b/packages/Tempest/tests/Fixture/AsyncQueue/AsyncQueueChannelConfiguration.php @@ -0,0 +1,20 @@ +handled = true; + } +} diff --git a/packages/Tempest/tests/Fixture/Counter/CounterGateway.php b/packages/Tempest/tests/Fixture/Counter/CounterGateway.php new file mode 100644 index 000000000..f06d9f340 --- /dev/null +++ b/packages/Tempest/tests/Fixture/Counter/CounterGateway.php @@ -0,0 +1,16 @@ +count++; + } + + #[QueryHandler('counter.get')] + public function get(): int + { + return $this->count; + } +} diff --git a/packages/Tempest/tests/Fixture/Dbal/DbalConnectionConfiguration.php b/packages/Tempest/tests/Fixture/Dbal/DbalConnectionConfiguration.php new file mode 100644 index 000000000..29a02f687 --- /dev/null +++ b/packages/Tempest/tests/Fixture/Dbal/DbalConnectionConfiguration.php @@ -0,0 +1,20 @@ +result = $calculatedValue; + } + + #[CommandHandler('calculator.multiplyWithEnv')] + public function multiplyWithEnv(#[Payload("parameter('ECOTONE_ENV_MULTIPLIER') * payload['value']")] int $calculatedValue): void + { + $this->envResult = $calculatedValue; + } + + #[QueryHandler('calculator.getResult')] + public function getResult(): int + { + return $this->result; + } + + #[QueryHandler('calculator.getEnvResult')] + public function getEnvResult(): int + { + return $this->envResult; + } +} diff --git a/packages/Tempest/tests/Fixture/ExpressionLanguage/ExpressionLanguageCommandHandler.php b/packages/Tempest/tests/Fixture/ExpressionLanguage/ExpressionLanguageCommandHandler.php new file mode 100644 index 000000000..b8c75e755 --- /dev/null +++ b/packages/Tempest/tests/Fixture/ExpressionLanguage/ExpressionLanguageCommandHandler.php @@ -0,0 +1,29 @@ +amount = $amount; + } + + #[QueryHandler('getAmount')] + public function getAmount(): int + { + return $this->amount; + } +} diff --git a/packages/Tempest/tests/Fixture/MultiTenant/Customer.php b/packages/Tempest/tests/Fixture/MultiTenant/Customer.php new file mode 100644 index 000000000..c83e9bb21 --- /dev/null +++ b/packages/Tempest/tests/Fixture/MultiTenant/Customer.php @@ -0,0 +1,29 @@ +customer_id = $command->customerId; + $customer->name = $command->name; + + return $customer; + } +} diff --git a/packages/Tempest/tests/Fixture/MultiTenant/CustomerInterface.php b/packages/Tempest/tests/Fixture/MultiTenant/CustomerInterface.php new file mode 100644 index 000000000..54503cd6c --- /dev/null +++ b/packages/Tempest/tests/Fixture/MultiTenant/CustomerInterface.php @@ -0,0 +1,16 @@ +register($command->customerId, $command->name); + } + + #[CommandHandler('customer.register_with_business_interface')] + public function handleWithDbalInterface(RegisterCustomer $command, CustomerInterface $customerInterface): void + { + $customerInterface->register($command->customerId, $command->name); + } + + #[QueryHandler('customer.getAllRegistered')] + public function getAllRegisteredPersonIds(#[Reference] CustomerRepository $customerRepository): array + { + return $customerRepository->getAllRegisteredPersonIds(); + } +} diff --git a/packages/Tempest/tests/Fixture/MultiTenant/MultiTenantEcotoneConfiguration.php b/packages/Tempest/tests/Fixture/MultiTenant/MultiTenantEcotoneConfiguration.php new file mode 100644 index 000000000..5ef582a5f --- /dev/null +++ b/packages/Tempest/tests/Fixture/MultiTenant/MultiTenantEcotoneConfiguration.php @@ -0,0 +1,27 @@ + TempestConnectionReference::create('tenant_a'), + 'tenant_b' => TempestConnectionReference::create('tenant_b'), + ], + ); + } +} diff --git a/packages/Tempest/tests/Fixture/MultiTenant/RegisterCustomer.php b/packages/Tempest/tests/Fixture/MultiTenant/RegisterCustomer.php new file mode 100644 index 000000000..dc60da6e5 --- /dev/null +++ b/packages/Tempest/tests/Fixture/MultiTenant/RegisterCustomer.php @@ -0,0 +1,17 @@ +user_id = $command->userId; + $order->total_price = $command->totalPrice; + $order->is_cancelled = false; + $order->save(); + + return $order; + } + + #[IdentifierMethod('id')] + public function getId(): int + { + return $this->id->value; + } + + #[CommandHandler(routingKey: 'cancel_order')] + public function cancel(): void + { + $this->is_cancelled = true; + } + + #[QueryHandler('is_cancelled')] + public function isCancelled(): bool + { + return $this->is_cancelled; + } +} diff --git a/packages/Tempest/tests/Fixture/Order/PlaceOrder.php b/packages/Tempest/tests/Fixture/Order/PlaceOrder.php new file mode 100644 index 000000000..24e33cabe --- /dev/null +++ b/packages/Tempest/tests/Fixture/Order/PlaceOrder.php @@ -0,0 +1,17 @@ +name = 'should-be-rolled-back'; + $item->save(); + + throw new RuntimeException('Intentional failure to trigger rollback'); + } +} diff --git a/packages/Tempest/tests/Fixture/SharedConnection/SharedConnectionConfiguration.php b/packages/Tempest/tests/Fixture/SharedConnection/SharedConnectionConfiguration.php new file mode 100644 index 000000000..2d1f0b7e4 --- /dev/null +++ b/packages/Tempest/tests/Fixture/SharedConnection/SharedConnectionConfiguration.php @@ -0,0 +1,20 @@ +name = 'should-be-rolled-back'; + $item->save(); + + throw new RuntimeException('Intentional failure to trigger multi-tenant rollback'); + } +} diff --git a/packages/Tempest/tests/Fixture/TenantSharedConnection/TenantSharedConnectionConfiguration.php b/packages/Tempest/tests/Fixture/TenantSharedConnection/TenantSharedConnectionConfiguration.php new file mode 100644 index 000000000..1625d98e1 --- /dev/null +++ b/packages/Tempest/tests/Fixture/TenantSharedConnection/TenantSharedConnectionConfiguration.php @@ -0,0 +1,32 @@ + TempestConnectionReference::create('tenant_a'), + ], + ); + } +} diff --git a/packages/Tempest/tests/Fixture/User/MessagingConfiguration.php b/packages/Tempest/tests/Fixture/User/MessagingConfiguration.php new file mode 100644 index 000000000..3b033087d --- /dev/null +++ b/packages/Tempest/tests/Fixture/User/MessagingConfiguration.php @@ -0,0 +1,20 @@ +userRepository->save(User::register($userId)); + } +} diff --git a/packages/Tempest/tests/MultiTenant/MultiTenantTest.php b/packages/Tempest/tests/MultiTenant/MultiTenantTest.php new file mode 100644 index 000000000..8e778b5e6 --- /dev/null +++ b/packages/Tempest/tests/MultiTenant/MultiTenantTest.php @@ -0,0 +1,145 @@ +setupKernel(); + + $this->container->config(TempestDatabaseConfigFactory::primary('tenant_a')); + $this->container->config(TempestDatabaseConfigFactory::secondary('tenant_b')); + + $this->createPersonsTableForBothTenants(); + + $this->commandBus = $this->container->get(CommandBus::class); + $this->queryBus = $this->container->get(QueryBus::class); + } + + protected function tearDown(): void + { + $this->dropPersonsTableForBothTenants(); + parent::tearDown(); + } + + public function test_run_message_handlers_for_multi_tenant_connection(): void + { + $this->commandBus->send(new RegisterCustomer(1, 'John Doe'), metadata: ['tenant' => 'tenant_a']); + $this->commandBus->send(new RegisterCustomer(2, 'John Doe'), metadata: ['tenant' => 'tenant_a']); + $this->commandBus->send(new RegisterCustomer(2, 'John Doe'), metadata: ['tenant' => 'tenant_b']); + + $this->assertSame( + [1, 2], + $this->queryBus->sendWithRouting('customer.getAllRegistered', metadata: ['tenant' => 'tenant_a']), + ); + + $this->assertSame( + [2], + $this->queryBus->sendWithRouting('customer.getAllRegistered', metadata: ['tenant' => 'tenant_b']), + ); + } + + public function test_using_dbal_based_business_interfaces(): void + { + $this->commandBus->sendWithRouting( + 'customer.register_with_business_interface', + new RegisterCustomer(1, 'John Doe'), + metadata: ['tenant' => 'tenant_a'], + ); + + $this->assertSame( + [1], + $this->queryBus->sendWithRouting('customer.getAllRegistered', metadata: ['tenant' => 'tenant_a']), + ); + + $this->assertSame( + [], + $this->queryBus->sendWithRouting('customer.getAllRegistered', metadata: ['tenant' => 'tenant_b']), + ); + } + + private function createPersonsTableForBothTenants(): void + { + $this->createPersonsTable($this->postgresConnection()); + $this->createPersonsTable($this->mysqlConnection()); + } + + private function dropPersonsTableForBothTenants(): void + { + $this->postgresConnection()->exec('DROP TABLE IF EXISTS persons'); + $this->mysqlConnection()->exec('DROP TABLE IF EXISTS persons'); + } + + private function createPersonsTable(PDO $pdo): void + { + $pdo->exec('DROP TABLE IF EXISTS persons'); + $pdo->exec( + 'CREATE TABLE persons ( + customer_id INTEGER NOT NULL, + name VARCHAR(255), + PRIMARY KEY (customer_id) + )', + ); + } + + private function postgresConnection(): PDO + { + $config = TempestDatabaseConfigFactory::primary(); + + return new PDO($config->dsn, $config->username, $config->password); + } + + private function mysqlConnection(): PDO + { + $config = TempestDatabaseConfigFactory::secondary(); + + return new PDO($config->dsn, $config->username, $config->password); + } +} diff --git a/packages/Tempest/tests/Repository/TempestRepositoryIntegrationTest.php b/packages/Tempest/tests/Repository/TempestRepositoryIntegrationTest.php new file mode 100644 index 000000000..d7a3ab880 --- /dev/null +++ b/packages/Tempest/tests/Repository/TempestRepositoryIntegrationTest.php @@ -0,0 +1,108 @@ +setupKernel(); + + $postgresConfig = TempestDatabaseConfigFactory::primary(); + $this->container->config($postgresConfig); + + $this->createOrdersTable(); + } + + protected function tearDown(): void + { + $this->dropOrdersTable(); + parent::tearDown(); + } + + public function test_placing_an_order_with_tempest_model(): void + { + $commandBus = $this->container->get(\Ecotone\Modelling\CommandBus::class); + + $orderId = $commandBus->send(new PlaceOrder(userId: 'user-1', totalPrice: 100)); + + $this->assertIsInt($orderId); + $this->assertNotNull(Order::findById($orderId)); + } + + public function test_state_change_round_trips_through_command_and_query_bus(): void + { + $commandBus = $this->container->get(\Ecotone\Modelling\CommandBus::class); + $queryBus = $this->container->get(\Ecotone\Modelling\QueryBus::class); + + $orderId = $commandBus->send(new PlaceOrder(userId: 'user-1', totalPrice: 100)); + + $this->assertFalse( + $queryBus->sendWithRouting('is_cancelled', metadata: ['aggregate.id' => $orderId]), + ); + + $commandBus->sendWithRouting('cancel_order', metadata: ['aggregate.id' => $orderId]); + + $this->assertTrue( + $queryBus->sendWithRouting('is_cancelled', metadata: ['aggregate.id' => $orderId]), + ); + } + + private function createOrdersTable(): void + { + $database = $this->container->get(Database::class); + + $database->execute( + new Query('DROP TABLE IF EXISTS orders'), + ); + + $createSql = (new CreateTableStatement('orders')) + ->primary('id') + ->string('user_id') + ->integer('total_price') + ->boolean('is_cancelled') + ->compile($database->dialect); + + $database->execute(new Query($createSql)); + } + + private function dropOrdersTable(): void + { + try { + $database = $this->container->get(Database::class); + $database->execute(new Query('DROP TABLE IF EXISTS orders')); + } catch (Throwable) { + } + } +} diff --git a/packages/Tempest/tests/Repository/TempestRepositoryTest.php b/packages/Tempest/tests/Repository/TempestRepositoryTest.php new file mode 100644 index 000000000..670372001 --- /dev/null +++ b/packages/Tempest/tests/Repository/TempestRepositoryTest.php @@ -0,0 +1,38 @@ +assertFalse($repository->canHandle(DateTime::class)); + } + + public function test_it_does_support_tempest_database_models(): void + { + $repository = new TempestRepository(); + + $modelClass = new class () { + use IsDatabaseModel; + + public PrimaryKey $id; + }; + + $this->assertTrue($repository->canHandle($modelClass::class)); + } +} diff --git a/packages/Tempest/tests/TempestConfigurationVariableServiceTest.php b/packages/Tempest/tests/TempestConfigurationVariableServiceTest.php new file mode 100644 index 000000000..1e39fd22f --- /dev/null +++ b/packages/Tempest/tests/TempestConfigurationVariableServiceTest.php @@ -0,0 +1,33 @@ +assertTrue($service->hasName('ECOTONE_TEST_VAR')); + $this->assertSame('test-value', $service->getByName('ECOTONE_TEST_VAR')); + } + + public function test_returns_false_for_missing_env_variable(): void + { + $service = new TempestConfigurationVariableService(); + + $this->assertFalse($service->hasName('ECOTONE_NONEXISTENT_VAR_XYZ')); + $this->assertNull($service->getByName('ECOTONE_NONEXISTENT_VAR_XYZ')); + } +} diff --git a/packages/Tempest/tests/TempestDatabaseConfigFactory.php b/packages/Tempest/tests/TempestDatabaseConfigFactory.php new file mode 100644 index 000000000..b4fdb126c --- /dev/null +++ b/packages/Tempest/tests/TempestDatabaseConfigFactory.php @@ -0,0 +1,47 @@ +handled = true; + } + + #[QueryHandler('app.wasHandled')] + public function wasHandled(): bool + { + return $this->handled; + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index dc3995aa4..260ff1ae5 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -16,6 +16,7 @@ packages/PdoEventSourcing/src packages/JmsConverter/src packages/Laravel/src + packages/Tempest/src packages/Symfony/App packages/Symfony/DependencyInjection packages/Symfony/SymfonyBundle @@ -55,6 +56,9 @@ packages/Laravel/tests + + packages/Tempest/tests + packages/Symfony/tests