Skip to content

Commit

Permalink
Add integration tests and enhance CI workflow for multiple platforms
Browse files Browse the repository at this point in the history
Add integration tests with SQLite, MySQL, PostgreSQL.
Run SQLite tests on Windows and Linux.
Run MySQL/PostgreSQL tests on Linux.
  • Loading branch information
wazum committed Jan 3, 2025
1 parent aaa5943 commit e359ce5
Show file tree
Hide file tree
Showing 6 changed files with 391 additions and 15 deletions.
111 changes: 97 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,125 @@ on:
pull_request:

jobs:
phpunit:
name: PHPUnit with PHP ${{ matrix.php }} and Symfony ${{ matrix.symfony }} and Doctrine ${{ matrix.doctrine }}
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.*' ]
doctrine: [ '^2.5', '^3.0' ]
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
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
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:[email protected]:5432/test_db'

ecs:
name: Easy Coding Standard
Expand Down
1 change: 0 additions & 1 deletion tests/Functional/config.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

doctrine:

dbal:
Expand Down
196 changes: 196 additions & 0 deletions tests/Integration/DomainEventPersistenceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php

declare(strict_types=1);

namespace Headsnet\DomainEventsBundle\Integration;

use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\DoctrineBundle\Registry;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Headsnet\DomainEventsBundle\Domain\Model\DomainEvent;
use Headsnet\DomainEventsBundle\Domain\Model\EventStore;
use Headsnet\DomainEventsBundle\Domain\Model\StoredEvent;
use Headsnet\DomainEventsBundle\HeadsnetDomainEventsBundle;
use Headsnet\DomainEventsBundle\Integration\Fixtures\TestEntity;
use Headsnet\DomainEventsBundle\Integration\Fixtures\TestEvent;
use Nyholm\BundleTest\TestKernel;
use Ramsey\Uuid\Uuid;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpKernel\KernelInterface;

/**
* @group integration
*/
class DomainEventPersistenceTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
private EventStore $eventStore;

protected static function getKernelClass(): string
{
return TestKernel::class;
}

/**
* @param array<string, mixed> $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();
}
}
31 changes: 31 additions & 0 deletions tests/Integration/Fixtures/TestEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Headsnet\DomainEventsBundle\Integration\Fixtures;

use Doctrine\ORM\Mapping as ORM;
use Headsnet\DomainEventsBundle\Domain\Model\ContainsEvents;
use Headsnet\DomainEventsBundle\Domain\Model\RecordsEvents;
use Headsnet\DomainEventsBundle\Domain\Model\Traits\EventRecorderTrait;
use Ramsey\Uuid\Uuid;

#[ORM\Entity]
class TestEntity implements RecordsEvents, ContainsEvents
{
use EventRecorderTrait;

#[ORM\Id]
#[ORM\Column(type: 'string', length: 36)]
private string $id;

public function __construct()
{
$this->id = Uuid::uuid4()->toString();
}

public function getId(): string
{
return $this->id;
}
}
19 changes: 19 additions & 0 deletions tests/Integration/Fixtures/TestEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Headsnet\DomainEventsBundle\Integration\Fixtures;

use Headsnet\DomainEventsBundle\Domain\Model\DomainEvent;
use Headsnet\DomainEventsBundle\Domain\Model\Traits\DomainEventTrait;

class TestEvent implements DomainEvent
{
use DomainEventTrait;

public function __construct(string $aggregateRootId)
{
$this->aggregateRootId = $aggregateRootId;
$this->occurredOn = (new \DateTimeImmutable())->format(DomainEvent::MICROSECOND_DATE_FORMAT);
}
}
Loading

0 comments on commit e359ce5

Please sign in to comment.