diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a670ffe..cf17999 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,52 +2,139 @@ name: CI on: push: - branches: [main] pull_request: - branches: [main] jobs: - - phpunit: - name: PHPUnit with PHP ${{ matrix.php }} and Symfony ${{ matrix.symfony }} + phpunit-sqlite: + name: PHPUnit (PHP ${{ matrix.php }} - Symfony ${{ matrix.symfony }} - Doctrine ${{ matrix.doctrine }} - SQLite - ${{ matrix.operating-system }}) runs-on: ${{ matrix.operating-system }} strategy: fail-fast: false matrix: operating-system: [ ubuntu-latest, windows-latest ] - php: [ '8.1', '8.2', '8.3', '8.4' ] - symfony: [ '5.4.*', '6.4.*', '7.2.*' ] + php: ['8.1', '8.2', '8.3', '8.4'] + symfony: ['5.4.*', '6.4.*', '7.2.*'] + doctrine: ['^2.5', '^3.0'] exclude: - { php: '8.1', symfony: '7.2.*' } + - { php: '8.1', doctrine: '^3.0' } + - { symfony: '5.4.*', doctrine: '^3.0' } steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} tools: flex + extensions: pdo_sqlite coverage: none + - uses: ramsey/composer-install@v3 + env: + SYMFONY_REQUIRE: ${{ matrix.symfony }} + with: + composer-options: --with=doctrine/orm:${{ matrix.doctrine }} + - run: vendor/bin/phpunit tests + env: + DATABASE_URL: 'sqlite:///:memory:' + + phpunit-mysql: + name: PHPUnit (PHP ${{ matrix.php }} - Symfony ${{ matrix.symfony }} - Doctrine ${{ matrix.doctrine }} - MySQL) + runs-on: ubuntu-latest - - name: Install composer dependencies - uses: ramsey/composer-install@v3 + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: test_db + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4'] + symfony: ['5.4.*', '6.4.*', '7.2.*'] + doctrine: ['^2.5', '^3.0'] + exclude: + - { php: '8.1', symfony: '7.2.*' } + - { php: '8.1', doctrine: '^3.0' } + - { symfony: '5.4.*', doctrine: '^3.0' } + + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: flex + extensions: pdo_mysql + coverage: none + - uses: ramsey/composer-install@v3 env: SYMFONY_REQUIRE: ${{ matrix.symfony }} + with: + composer-options: --with=doctrine/orm:${{ matrix.doctrine }} + - run: vendor/bin/phpunit tests + env: + DATABASE_URL: 'mysql://root:@127.0.0.1:3306/test_db' + + phpunit-postgres: + name: PHPUnit (PHP ${{ matrix.php }} - Symfony ${{ matrix.symfony }} - Doctrine ${{ matrix.doctrine }} - PostgreSQL) + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4'] + symfony: ['5.4.*', '6.4.*', '7.2.*'] + doctrine: ['^2.5', '^3.0'] + exclude: + - { php: '8.1', symfony: '7.2.*' } + - { php: '8.1', doctrine: '^3.0' } + - { symfony: '5.4.*', doctrine: '^3.0' } - - name: Run test suite on PHP ${{ matrix.php }} and Symfony ${{ matrix.symfony }} - run: vendor/bin/phpunit tests + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: flex + extensions: pdo_pgsql + coverage: none + - uses: ramsey/composer-install@v3 + env: + SYMFONY_REQUIRE: ${{ matrix.symfony }} + with: + composer-options: --with=doctrine/orm:${{ matrix.doctrine }} + - run: vendor/bin/phpunit tests + env: + DATABASE_URL: 'postgresql://postgres:postgres@127.0.0.1:5432/test_db' ecs: name: Easy Coding Standard runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: latest + php-version: latest + coverage: none - uses: ramsey/composer-install@v3 - run: vendor/bin/ecs @@ -55,9 +142,10 @@ jobs: name: PHPStan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: latest + coverage: none - uses: ramsey/composer-install@v3 - run: vendor/bin/phpstan diff --git a/composer.json b/composer.json index 5004da9..7c3436f 100644 --- a/composer.json +++ b/composer.json @@ -15,9 +15,9 @@ }, "require": { "php": "^8.1", - "doctrine/orm": "^2.5", + "doctrine/orm": "^2.5 || ^3.0", "doctrine/doctrine-bundle": "^2.0", - "ramsey/uuid-doctrine": "^1.5", + "ramsey/uuid-doctrine": "^1.5 || ^2.0", "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", "symfony/framework-bundle": "^5.4 || ^6.4 || ^7.0", "symfony/lock": "^5.4 || ^6.4 || ^7.0", diff --git a/tests/Functional/config.yml b/tests/Functional/config.yml index 6bc24aa..277344f 100644 --- a/tests/Functional/config.yml +++ b/tests/Functional/config.yml @@ -1,4 +1,3 @@ - doctrine: dbal: diff --git a/tests/Integration/DomainEventPersistenceTest.php b/tests/Integration/DomainEventPersistenceTest.php new file mode 100644 index 0000000..1f3b612 --- /dev/null +++ b/tests/Integration/DomainEventPersistenceTest.php @@ -0,0 +1,196 @@ + $options + */ + protected static function createKernel(array $options = []): KernelInterface + { + /** @var TestKernel $kernel */ + $kernel = parent::createKernel($options); + $kernel->addTestConfig(__DIR__ . '/config.yml'); + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestBundle(DoctrineBundle::class); + $kernel->addTestBundle(HeadsnetDomainEventsBundle::class); + $kernel->handleOptions($options); + + return $kernel; + } + + protected function setUp(): void + { + self::bootKernel(); + + $container = self::getContainer(); + + /** @var Registry $doctrine */ + $doctrine = $container->get('doctrine'); + /** @var EntityManagerInterface $entityManager */ + $entityManager = $doctrine->getManager(); + $this->entityManager = $entityManager; + + /** @var EventStore $eventStore */ + $eventStore = $container->get(EventStore::class); + $this->eventStore = $eventStore; + + $schemaTool = new SchemaTool($this->entityManager); + $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + $schemaTool->dropSchema($metadata); + $schemaTool->createSchema($metadata); + } + + public function testDomainEventIsPersistedWithCorrectData(): void + { + $entity = new TestEntity(); + $event = new TestEvent($entity->getId()); + + $entity->record($event); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + $this->entityManager->clear(); + + /** @var StoredEvent[] $storedEvents */ + $storedEvents = $this->entityManager->getRepository(StoredEvent::class)->findAll(); + self::assertCount(1, $storedEvents); + self::assertEquals($event->getAggregateRootId(), $storedEvents[0]->getAggregateRoot()); + /** @var \DateTimeImmutable $expectedDate */ + $expectedDate = \DateTimeImmutable::createFromFormat(DomainEvent::MICROSECOND_DATE_FORMAT, $event->getOccurredOn()); + /** @var \DateTimeImmutable $actualDate */ + $actualDate = $storedEvents[0]->getOccurredOn(); + $platform = $this->entityManager->getConnection()->getDatabasePlatform(); + + if ($this->isMicrosecondPlatform($platform)) { + // MySQL and PostgreSQL support microseconds + self::assertEquals( + $expectedDate->format('Y-m-d\TH:i:s.u'), + $actualDate->format('Y-m-d\TH:i:s.u'), + 'Dates should match with microsecond precision' + ); + } else { + // SQLite and others: compare only up to seconds + self::assertEquals( + $expectedDate->format('Y-m-d H:i:s'), + $actualDate->format('Y-m-d H:i:s'), + 'Dates should match up to seconds precision' + ); + } + self::assertNull($storedEvents[0]->getPublishedOn()); + } + + public function testReplaceableDomainEventReplacesUnpublishedEvent(): void + { + $aggregateId = Uuid::uuid4()->toString(); + $firstEvent = new TestEvent($aggregateId); + $secondEvent = new TestEvent($aggregateId); + + // Store event + $this->eventStore->append($firstEvent); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $events = $this->entityManager->getRepository(StoredEvent::class)->findAll(); + self::assertCount(1, $events); + $firstStoredEventId = $events[0]->getEventId()->asString(); + + // Replace event + $this->eventStore->replace($secondEvent); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $events = $this->entityManager->getRepository(StoredEvent::class)->findAll(); + self::assertCount(1, $events, 'Should still have only one event'); + self::assertNotEquals($firstStoredEventId, $events[0]->getEventId()->asString(), 'Should have a different event ID'); + self::assertEquals($aggregateId, $events[0]->getAggregateRoot()); + } + + public function testPublishedDomainEventIsNotReplaced(): void + { + $aggregateId = Uuid::uuid4()->toString(); + $firstEvent = new TestEvent($aggregateId); + + $this->eventStore->append($firstEvent); + $this->entityManager->flush(); + $this->entityManager->clear(); + $storedEvent = $this->entityManager->getRepository(StoredEvent::class)->findAll()[0]; + $this->eventStore->publish($storedEvent); + $this->entityManager->flush(); + $this->entityManager->clear(); + + // Attempt to replace published event + $secondEvent = new TestEvent($aggregateId); + $this->eventStore->replace($secondEvent); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $events = $this->entityManager->getRepository(StoredEvent::class)->findAll(); + self::assertCount(2, $events, 'Should have two events'); + self::assertEquals($aggregateId, $events[0]->getAggregateRoot()); + self::assertEquals($aggregateId, $events[1]->getAggregateRoot()); + self::assertNotNull($events[0]->getPublishedOn(), 'First event should be published'); + self::assertNull($events[1]->getPublishedOn(), 'Second event should not be published'); + } + + public function testUnpublishedEventsCanBeRetrieved(): void + { + $aggregateId = Uuid::uuid4()->toString(); + $event = new TestEvent($aggregateId); + + $this->eventStore->append($event); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $unpublishedEvents = $this->eventStore->allUnpublished(); + self::assertCount(1, $unpublishedEvents); + self::assertEquals($aggregateId, $unpublishedEvents[0]->getAggregateRoot()); + } + + private function isMicrosecondPlatform(AbstractPlatform $platform): bool + { + $platformClass = get_class($platform); + $microsecondPlatformClasses = [ + 'Doctrine\\DBAL\\Platforms\\MySQLPlatform', + 'Doctrine\\DBAL\\Platforms\\PostgreSQLPlatform' + ]; + + return in_array($platformClass, $microsecondPlatformClasses); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->entityManager->close(); + } +} diff --git a/tests/Integration/Fixtures/TestEntity.php b/tests/Integration/Fixtures/TestEntity.php new file mode 100644 index 0000000..e146ddb --- /dev/null +++ b/tests/Integration/Fixtures/TestEntity.php @@ -0,0 +1,31 @@ +id = Uuid::uuid4()->toString(); + } + + public function getId(): string + { + return $this->id; + } +} diff --git a/tests/Integration/Fixtures/TestEvent.php b/tests/Integration/Fixtures/TestEvent.php new file mode 100644 index 0000000..b952df5 --- /dev/null +++ b/tests/Integration/Fixtures/TestEvent.php @@ -0,0 +1,19 @@ +aggregateRootId = $aggregateRootId; + $this->occurredOn = (new \DateTimeImmutable())->format(DomainEvent::MICROSECOND_DATE_FORMAT); + } +} diff --git a/tests/Integration/config.yml b/tests/Integration/config.yml new file mode 100644 index 0000000..05a6e8c --- /dev/null +++ b/tests/Integration/config.yml @@ -0,0 +1,48 @@ +doctrine: + + dbal: + default_connection: default + connections: + default: + url: 'sqlite:///:memory:' + profiling: true + charset: UTF8 + + orm: + default_entity_manager: default + auto_generate_proxy_classes: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + auto_mapping: true + mappings: + TestFixtures: + is_bundle: false + type: attribute + dir: "%kernel.project_dir%/tests/Integration/Fixtures" + prefix: 'Headsnet\DomainEventsBundle\Integration\Fixtures' + +framework: + + messenger: + + default_bus: messenger.bus.event + + buses: + messenger.bus.event: + default_middleware: allow_no_handlers + + serializer: ~ + +# Create test aliases for services, so they are not automatically removed from the container. +services: + + test.headsnet_domain_events.event_subscriber.publisher: + alias: headsnet_domain_events.event_subscriber.publisher + public: true + + test.headsnet_domain_events.event_subscriber.persister: + alias: headsnet_domain_events.event_subscriber.persister + public: true + + test.headsnet_domain_events.repository.event_store_doctrine: + alias: headsnet_domain_events.repository.event_store_doctrine + public: true