diff --git a/baseline.xml b/baseline.xml index 6ba800a47..09621b43a 100644 --- a/baseline.xml +++ b/baseline.xml @@ -1,5 +1,5 @@ - + new static() @@ -188,6 +188,23 @@ $name + + + 'bar', + 'aggregateClass' => Profile::class, + 'aggregateId' => '1', + 'playhead' => 3, + 'recordedOn' => $recordedAt, + 'newStreamStart' => true, + 'archived' => true, + ]]]> + + + assertSame + assertSame + + $value diff --git a/infection.json.dist b/infection.json.dist index 4f4727e31..58734de82 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -14,6 +14,6 @@ "@default": true }, "minMsi": 57, - "minCoveredMsi": 90, + "minCoveredMsi": 87, "testFrameworkOptions": "--testsuite=unit" } diff --git a/src/EventBus/ListenerDescriptor.php b/src/EventBus/ListenerDescriptor.php index a2e4b5842..80b01c2dd 100644 --- a/src/EventBus/ListenerDescriptor.php +++ b/src/EventBus/ListenerDescriptor.php @@ -16,38 +16,36 @@ final class ListenerDescriptor public function __construct(callable $callable) { - $callable = $callable(...); + $this->callable = $callable(...); + $this->name = self::closureName($this->callable); + } + + public function name(): string + { + return $this->name; + } - $this->callable = $callable; + public function callable(): callable + { + return $this->callable; + } - $reflectionFunction = new ReflectionFunction($callable); + private static function closureName(Closure $closure): string + { + $reflectionFunction = new ReflectionFunction($closure); if (method_exists($reflectionFunction, 'isAnonymous') && $reflectionFunction->isAnonymous()) { - $this->name = 'Closure'; - - return; + return 'Closure'; } - $callable = $reflectionFunction->getClosureThis(); + $closureThis = $reflectionFunction->getClosureThis(); - if (!$callable) { + if (!$closureThis) { $class = $reflectionFunction->getClosureCalledClass(); - $this->name = ($class ? $class->name . '::' : '') . $reflectionFunction->name; - - return; + return ($class ? $class->name . '::' : '') . $reflectionFunction->name; } - $this->name = $callable::class . '::' . $reflectionFunction->name; - } - - public function name(): string - { - return $this->name; - } - - public function callable(): callable - { - return $this->callable; + return $closureThis::class . '::' . $reflectionFunction->name; } } diff --git a/src/EventBus/Message.php b/src/EventBus/Message.php index 3236d14cc..b0e4de0a6 100644 --- a/src/EventBus/Message.php +++ b/src/EventBus/Message.php @@ -8,12 +8,18 @@ use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use function array_key_exists; -use function array_keys; /** * @template-covariant T of object * @psalm-immutable - * @psalm-type Headers = array{aggregateClass?: class-string, aggregateId?:string, playhead?:positive-int, recordedOn?: DateTimeImmutable, newStreamStart?: bool, archived?: bool} + * @psalm-type Headers = array{ + * aggregateClass?: class-string, + * aggregateId?: string, + * playhead?: positive-int, + * recordedOn?: DateTimeImmutable, + * newStreamStart?: bool, + * archived?: bool + * } */ final class Message { @@ -179,7 +185,7 @@ public function withArchived(bool $value): self /** @throws HeaderNotFound */ public function customHeader(string $name): mixed { - if (array_keys($this->customHeaders, $name)) { + if (!array_key_exists($name, $this->customHeaders)) { throw HeaderNotFound::custom($name); } diff --git a/src/Pipeline/Middleware/RecalculatePlayheadMiddleware.php b/src/Pipeline/Middleware/RecalculatePlayheadMiddleware.php index d2f5addea..89204bf4b 100644 --- a/src/Pipeline/Middleware/RecalculatePlayheadMiddleware.php +++ b/src/Pipeline/Middleware/RecalculatePlayheadMiddleware.php @@ -28,6 +28,11 @@ public function __invoke(Message $message): array ]; } + public function reset(): void + { + $this->index = []; + } + /** * @param class-string $aggregateClass * diff --git a/tests/Unit/Aggregate/AggregateRootIdNotSupportedTest.php b/tests/Unit/Aggregate/AggregateRootIdNotSupportedTest.php new file mode 100644 index 000000000..8b0246782 --- /dev/null +++ b/tests/Unit/Aggregate/AggregateRootIdNotSupportedTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Aggregate/AggregateRootTest.php b/tests/Unit/Aggregate/AggregateRootTest.php index f8c9f23b8..b9661c3b8 100644 --- a/tests/Unit/Aggregate/AggregateRootTest.php +++ b/tests/Unit/Aggregate/AggregateRootTest.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Aggregate; +use Patchlevel\EventSourcing\Aggregate\AggregateRootIdNotSupported; use Patchlevel\EventSourcing\Aggregate\ApplyMethodNotFound; use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Aggregate\MetadataNotPossible; @@ -16,10 +17,16 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileInvalid; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithBrokenId; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithSuppressAll; use PHPUnit\Framework\TestCase; -/** @covers \Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot */ +/** + * @covers \Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot + * @covers \Patchlevel\EventSourcing\Aggregate\AggregateRootBehaviour + * @covers \Patchlevel\EventSourcing\Aggregate\AggregateRootAttributeBehaviour + * @covers \Patchlevel\EventSourcing\Aggregate\AggregateRootMetadataAwareBehaviour + */ final class AggregateRootTest extends TestCase { public function testApplyMethod(): void @@ -162,4 +169,24 @@ public function testMetadataNotPossible(): void BasicAggregateRoot::metadata(); } + + public function testCachedAggregateId(): void + { + $profileId = ProfileId::fromString('1'); + $email = Email::fromString('hallo@patchlevel.de'); + + $profile = Profile::createProfile($profileId, $email); + $id = $profile->aggregateRootId(); + + self::assertSame($profileId, $id); + self::assertSame($id, $profile->aggregateRootId()); + } + + public function testInvalidAggregateId(): void + { + $aggregate = ProfileWithBrokenId::create(); + + $this->expectException(AggregateRootIdNotSupported::class); + $aggregate->aggregateRootId(); + } } diff --git a/tests/Unit/Aggregate/ApplyMethodNotFoundTest.php b/tests/Unit/Aggregate/ApplyMethodNotFoundTest.php index 71a53f4aa..e3e4635c4 100644 --- a/tests/Unit/Aggregate/ApplyMethodNotFoundTest.php +++ b/tests/Unit/Aggregate/ApplyMethodNotFoundTest.php @@ -9,6 +9,7 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use PHPUnit\Framework\TestCase; +/** @covers \Patchlevel\EventSourcing\Aggregate\ApplyMethodNotFound */ final class ApplyMethodNotFoundTest extends TestCase { public function testCreate(): void @@ -19,5 +20,6 @@ public function testCreate(): void 'Apply method in "Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile" could not be found for the event "Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated"', $exception->getMessage(), ); + self::assertSame(0, $exception->getCode()); } } diff --git a/tests/Unit/Aggregate/CustomIdTest.php b/tests/Unit/Aggregate/CustomIdTest.php new file mode 100644 index 000000000..4367161bc --- /dev/null +++ b/tests/Unit/Aggregate/CustomIdTest.php @@ -0,0 +1,19 @@ +toString()); + } +} diff --git a/tests/Unit/Aggregate/MetadataNotPossibleTest.php b/tests/Unit/Aggregate/MetadataNotPossibleTest.php new file mode 100644 index 000000000..70ff23251 --- /dev/null +++ b/tests/Unit/Aggregate/MetadataNotPossibleTest.php @@ -0,0 +1,23 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Aggregate/UuidTest.php b/tests/Unit/Aggregate/UuidTest.php new file mode 100644 index 000000000..83a5d02d7 --- /dev/null +++ b/tests/Unit/Aggregate/UuidTest.php @@ -0,0 +1,56 @@ +toString()); + } + + public function testV6(): void + { + $factory = new class extends UuidFactory + { + public function uuid6(Hexadecimal|null $node = null, int|null $clockSeq = null): UuidInterface + { + return RamseyUuid::fromString('1eec1e5c-e397-6644-9aed-0242ac110002'); + } + }; + + RamseyUuid::setFactory($factory); + $id = Uuid::v6(); + + self::assertSame('1eec1e5c-e397-6644-9aed-0242ac110002', $id->toString()); + } + + public function testV7(): void + { + $factory = new class extends UuidFactory + { + public function uuid7(DateTimeInterface|null $dateTime = null): UuidInterface + { + return RamseyUuid::fromString('018d6a97-6aba-7104-825f-67313a77a2a4'); + } + }; + + RamseyUuid::setFactory($factory); + $id = Uuid::v7(); + + self::assertSame('018d6a97-6aba-7104-825f-67313a77a2a4', $id->toString()); + } +} diff --git a/tests/Unit/Attribute/AggregateTest.php b/tests/Unit/Attribute/AggregateTest.php new file mode 100644 index 000000000..fae7e918c --- /dev/null +++ b/tests/Unit/Attribute/AggregateTest.php @@ -0,0 +1,19 @@ +name); + } +} diff --git a/tests/Unit/Attribute/ApplyTest.php b/tests/Unit/Attribute/ApplyTest.php new file mode 100644 index 000000000..e5b638c68 --- /dev/null +++ b/tests/Unit/Attribute/ApplyTest.php @@ -0,0 +1,20 @@ +eventClass); + } +} diff --git a/tests/Unit/Attribute/EventTest.php b/tests/Unit/Attribute/EventTest.php new file mode 100644 index 000000000..ae61deb68 --- /dev/null +++ b/tests/Unit/Attribute/EventTest.php @@ -0,0 +1,19 @@ +name); + } +} diff --git a/tests/Unit/Attribute/IdTest.php b/tests/Unit/Attribute/IdTest.php new file mode 100644 index 000000000..c638e6b31 --- /dev/null +++ b/tests/Unit/Attribute/IdTest.php @@ -0,0 +1,19 @@ +name); + self::assertSame(0, $attribute->version); + } +} diff --git a/tests/Unit/Attribute/SetupTest.php b/tests/Unit/Attribute/SetupTest.php new file mode 100644 index 000000000..33919033f --- /dev/null +++ b/tests/Unit/Attribute/SetupTest.php @@ -0,0 +1,19 @@ +name); + self::assertNull($attribute->batch); + self::assertNull($attribute->version); + } +} diff --git a/tests/Unit/Attribute/SplitStreamTest.php b/tests/Unit/Attribute/SplitStreamTest.php new file mode 100644 index 000000000..2ab30a792 --- /dev/null +++ b/tests/Unit/Attribute/SplitStreamTest.php @@ -0,0 +1,19 @@ +eventClass); + } +} diff --git a/tests/Unit/Attribute/TeardownTest.php b/tests/Unit/Attribute/TeardownTest.php new file mode 100644 index 000000000..1b902f20f --- /dev/null +++ b/tests/Unit/Attribute/TeardownTest.php @@ -0,0 +1,19 @@ +getMessage()); self::assertSame($expectedValue, $exception->value()); + self::assertSame(0, $exception->getCode()); } } diff --git a/tests/Unit/EventBus/AttributeListenerProviderTest.php b/tests/Unit/EventBus/AttributeListenerProviderTest.php index 08844fd9d..3d2108a15 100644 --- a/tests/Unit/EventBus/AttributeListenerProviderTest.php +++ b/tests/Unit/EventBus/AttributeListenerProviderTest.php @@ -130,4 +130,30 @@ public function bar(Message $message): void new ListenerDescriptor($listener->foo(...)), ], $listeners); } + + public function testCaching(): void + { + $listener = new class { + #[Subscribe('*')] + public function foo(Message $message): void + { + } + + #[Subscribe(ProfileCreated::class)] + public function bar(Message $message): void + { + } + }; + + $eventBus = new AttributeListenerProvider([$listener]); + $listeners = $eventBus->listenersForEvent(ProfileCreated::class); + + self::assertEquals([ + new ListenerDescriptor($listener->bar(...)), + new ListenerDescriptor($listener->foo(...)), + ], $listeners); + + $cachedListeners = $eventBus->listenersForEvent(ProfileCreated::class); + self::assertSame($listeners, $cachedListeners); + } } diff --git a/tests/Unit/EventBus/Decorator/SplitStreamDecoratorTest.php b/tests/Unit/EventBus/Decorator/SplitStreamDecoratorTest.php new file mode 100644 index 000000000..c77843e5f --- /dev/null +++ b/tests/Unit/EventBus/Decorator/SplitStreamDecoratorTest.php @@ -0,0 +1,51 @@ +newStreamStart()); + } + + public function testSplittingStream(): void + { + $message = new Message( + new SplittingEvent( + Email::fromString('info@patchlevel.de'), + 1, + ), + ); + + $decorator = new SplitStreamDecorator(new AttributeEventMetadataFactory()); + $decoratedMessage = $decorator($message); + + self::assertTrue($decoratedMessage->newStreamStart()); + } +} diff --git a/tests/Unit/EventBus/DefaultEventBusTest.php b/tests/Unit/EventBus/DefaultEventBusTest.php index b255ccdbb..e8d213b4c 100644 --- a/tests/Unit/EventBus/DefaultEventBusTest.php +++ b/tests/Unit/EventBus/DefaultEventBusTest.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Tests\Unit\EventBus; +use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\EventBus\DefaultEventBus; use Patchlevel\EventSourcing\EventBus\ListenerDescriptor; use Patchlevel\EventSourcing\EventBus\ListenerProvider; @@ -50,6 +51,31 @@ public function __invoke(Message $message): void self::assertSame($message, $listener->message); } + public function testDispatchEventWithSubscribe(): void + { + $listener = new class { + public Message|null $message = null; + + #[Subscribe(ProfileCreated::class)] + public function __invoke(Message $message): void + { + $this->message = $message; + } + }; + + $message = new Message( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + ); + + $eventBus = DefaultEventBus::create([$listener]); + $eventBus->dispatch($message); + + self::assertSame($message, $listener->message); + } + public function testDispatchMultipleMessages(): void { $listener = new class { diff --git a/tests/Unit/EventBus/HeaderNotFoundTest.php b/tests/Unit/EventBus/HeaderNotFoundTest.php new file mode 100644 index 000000000..477e550e7 --- /dev/null +++ b/tests/Unit/EventBus/HeaderNotFoundTest.php @@ -0,0 +1,52 @@ +getMessage(), + ); + } + + public function testAggregateId(): void + { + self::assertSame( + 'message header "aggregateId" is not defined', + HeaderNotFound::aggregateId()->getMessage(), + ); + } + + public function testPlayhead(): void + { + self::assertSame( + 'message header "playhead" is not defined', + HeaderNotFound::playhead()->getMessage(), + ); + } + + public function testRecordedOn(): void + { + self::assertSame( + 'message header "recordedOn" is not defined', + HeaderNotFound::recordedOn()->getMessage(), + ); + } + + public function testCustom(): void + { + self::assertSame( + 'message header "foo" is not defined', + HeaderNotFound::custom('foo')->getMessage(), + ); + } +} diff --git a/tests/Unit/EventBus/ListenerDescriptorTest.php b/tests/Unit/EventBus/ListenerDescriptorTest.php index 3b4f45070..5b705b532 100644 --- a/tests/Unit/EventBus/ListenerDescriptorTest.php +++ b/tests/Unit/EventBus/ListenerDescriptorTest.php @@ -9,9 +9,10 @@ use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\TestCase; +/** @covers \Patchlevel\EventSourcing\EventBus\ListenerDescriptor */ final class ListenerDescriptorTest extends TestCase { - public function testClass(): void + public function testObjectMethod(): void { $listener = new DummyListener(); @@ -21,6 +22,16 @@ public function testClass(): void self::assertEquals('Patchlevel\EventSourcing\Tests\Unit\Fixture\DummyListener::__invoke', $descriptor->name()); } + public function testStaticObjectMethod(): void + { + $listener = new DummyListener(); + + $descriptor = new ListenerDescriptor([$listener, 'foo']); + + self::assertEquals($listener->__invoke(...), $descriptor->callable()); + self::assertEquals('Patchlevel\EventSourcing\Tests\Unit\Fixture\DummyListener::foo', $descriptor->name()); + } + #[RequiresPhp('>= 8.2')] public function testAnonymousFunction(): void { diff --git a/tests/Unit/EventBus/MessageTest.php b/tests/Unit/EventBus/MessageTest.php index 596721188..70d135e6b 100644 --- a/tests/Unit/EventBus/MessageTest.php +++ b/tests/Unit/EventBus/MessageTest.php @@ -5,12 +5,14 @@ namespace Patchlevel\EventSourcing\Tests\Unit\EventBus; use DateTimeImmutable; +use Generator; use Patchlevel\EventSourcing\EventBus\HeaderNotFound; use Patchlevel\EventSourcing\EventBus\Message; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** @covers \Patchlevel\EventSourcing\EventBus\Message */ @@ -18,112 +20,223 @@ final class MessageTest extends TestCase { public function testEmptyMessage(): void { - $id = ProfileId::fromString('1'); - $email = Email::fromString('hallo@patchlevel.de'); - $event = new ProfileCreated( - $id, - $email, + ProfileId::fromString('1'), + Email::fromString('hallo@patchlevel.de'), ); - $message = new Message( - $event, - ); + $message = new Message($event); self::assertEquals($event, $message->event()); } - public function testCreateMessageWithHeader(): void + public function testCreateMessageWithAggregateHeader(): void { - $recordedAt = new DateTimeImmutable('2020-05-06 13:34:24'); + $message = Message::create(new class { + }) + ->withAggregateClass(Profile::class); - $id = ProfileId::fromString('1'); - $email = Email::fromString('hallo@patchlevel.de'); - - $event = new ProfileCreated( - $id, - $email, - ); + self::assertSame(Profile::class, $message->aggregateClass()); + } - $message = Message::create($event) - ->withAggregateClass(Profile::class) - ->withAggregateId('1') - ->withPlayhead(1) - ->withRecordedOn($recordedAt); + public function testCreateMessageWithAggregateIdHeader(): void + { + $message = Message::create(new class { + }) + ->withAggregateId('1'); - self::assertEquals($event, $message->event()); - self::assertSame(Profile::class, $message->aggregateClass()); self::assertSame('1', $message->aggregateId()); - self::assertSame(1, $message->playhead()); - self::assertEquals($recordedAt, $message->recordedOn()); } - public function testChangeHeader(): void + public function testCreateMessageWithPlayheadHeader(): void { - $recordedAt = new DateTimeImmutable('2020-05-06 13:34:24'); + $message = Message::create(new class { + }) + ->withPlayhead(1); - $id = ProfileId::fromString('1'); - $email = Email::fromString('hallo@patchlevel.de'); + self::assertSame(1, $message->playhead()); + } - $event = new ProfileCreated( - $id, - $email, - ); + public function testCreateMessageWithRecordedOnHeader(): void + { + $recordedAt = new DateTimeImmutable('2020-05-06 13:34:24'); - $message = Message::create($event) - ->withAggregateClass(Profile::class) - ->withAggregateId('1') - ->withPlayhead(1) + $message = Message::create(new class { + }) ->withRecordedOn($recordedAt); - $message = $message->withPlayhead(2); - - self::assertSame(Profile::class, $message->aggregateClass()); - self::assertSame('1', $message->aggregateId()); - self::assertSame(2, $message->playhead()); self::assertEquals($recordedAt, $message->recordedOn()); } - public function testHeaderNotFound(): void + public function testCreateMessageWithCustomHeader(): void { - $this->expectException(HeaderNotFound::class); + $message = Message::create(new class { + }) + ->withCustomHeader('custom-field', 'foo-bar'); - $id = ProfileId::fromString('1'); - $email = Email::fromString('hallo@patchlevel.de'); + self::assertEquals('foo-bar', $message->customHeader('custom-field')); + self::assertEquals( + ['custom-field' => 'foo-bar'], + $message->customHeaders(), + ); + } - $message = new Message( - new ProfileCreated( - $id, - $email, - ), + public function testCreateMessageWithCustomHeaders(): void + { + $message = Message::create(new class { + }) + ->withCustomHeaders(['custom-field' => 'foo-bar']); + + self::assertEquals('foo-bar', $message->customHeader('custom-field')); + self::assertEquals( + ['custom-field' => 'foo-bar'], + $message->customHeaders(), ); + } - /** @psalm-suppress UnusedMethodCall */ - $message->aggregateClass(); + public function testCreateMessageWithNewStreamStartHeader(): void + { + $message = Message::create(new class { + }) + ->withNewStreamStart(true); + + self::assertTrue($message->newStreamStart()); } - public function testCustomHeaders(): void + public function testCreateMessageWithArchivedHeader(): void { - $recordedAt = new DateTimeImmutable('2020-05-06 13:34:24'); + $message = Message::create(new class { + }) + ->withArchived(true); - $id = ProfileId::fromString('1'); - $email = Email::fromString('hallo@patchlevel.de'); + self::assertTrue($message->archived()); + } - $event = new ProfileCreated( - $id, - $email, + public function testChangeHeader(): void + { + $message = Message::create(new class { + }) + ->withPlayhead(1); + self::assertSame(1, $message->playhead()); + + $message = $message->withPlayhead(2); + self::assertSame(2, $message->playhead()); + } + + public function testEmptyAllHeaders(): void + { + $message = Message::create(new class { + }); + + self::assertSame( + [ + 'newStreamStart' => false, + 'archived' => false, + ], + $message->headers(), ); + } + + public function testAllHeaders(): void + { + $recordedAt = new DateTimeImmutable('2020-05-06 13:34:24'); - $message = Message::create($event) + $message = Message::create(new class { + }) ->withAggregateClass(Profile::class) ->withAggregateId('1') - ->withPlayhead(1) + ->withPlayhead(3) ->withRecordedOn($recordedAt) - ->withCustomHeader('custom-field', 'foo-bar'); + ->withArchived(true) + ->withNewStreamStart(true) + ->withCustomHeader('foo', 'bar'); + + self::assertSame( + [ + 'foo' => 'bar', + 'aggregateClass' => Profile::class, + 'aggregateId' => '1', + 'playhead' => 3, + 'recordedOn' => $recordedAt, + 'newStreamStart' => true, + 'archived' => true, + ], + $message->headers(), + ); + } - self::assertEquals( - ['custom-field' => 'foo-bar'], - $message->customHeaders(), + public function testCreateWithEmptyHeaders(): void + { + $message = Message::createWithHeaders(new class { + }, []); + + self::assertSame( + [ + 'newStreamStart' => false, + 'archived' => false, + ], + $message->headers(), + ); + } + + public function testCreateWithAllHeaders(): void + { + $recordedAt = new DateTimeImmutable('2020-05-06 13:34:24'); + $message = Message::createWithHeaders( + new class { + }, + [ + 'foo' => 'bar', + 'aggregateClass' => Profile::class, + 'aggregateId' => '1', + 'playhead' => 3, + 'recordedOn' => $recordedAt, + 'newStreamStart' => true, + 'archived' => true, + ], ); + + self::assertSame( + [ + 'foo' => 'bar', + 'aggregateClass' => Profile::class, + 'aggregateId' => '1', + 'playhead' => 3, + 'recordedOn' => $recordedAt, + 'newStreamStart' => true, + 'archived' => true, + ], + $message->headers(), + ); + } + + #[DataProvider('provideHeaderNotFound')] + public function testHeaderNotFound(string $headerName): void + { + $message = Message::create(new class { + }); + + $this->expectException(HeaderNotFound::class); + /** @psalm-suppress UnusedMethodCall */ + $message->{$headerName}(); + } + + /** @return Generator */ + public static function provideHeaderNotFound(): Generator + { + yield 'aggregateClass' => ['aggregateClass']; + yield 'aggregateId' => ['aggregateId']; + yield 'playhead' => ['playhead']; + yield 'recordedOn' => ['recordedOn']; + } + + public function testCustomHeaderNotFound(): void + { + $message = Message::create(new class { + }); + + $this->expectException(HeaderNotFound::class); + /** @psalm-suppress UnusedMethodCall */ + $message->customHeader('foo'); } } diff --git a/tests/Unit/Fixture/DummyListener.php b/tests/Unit/Fixture/DummyListener.php index f496044c4..bc1a35a2f 100644 --- a/tests/Unit/Fixture/DummyListener.php +++ b/tests/Unit/Fixture/DummyListener.php @@ -9,4 +9,8 @@ final class DummyListener public function __invoke(): void { } + + public static function foo(): void + { + } } diff --git a/tests/Unit/Fixture/ProfileWithBrokenId.php b/tests/Unit/Fixture/ProfileWithBrokenId.php new file mode 100644 index 000000000..c14db2056 --- /dev/null +++ b/tests/Unit/Fixture/ProfileWithBrokenId.php @@ -0,0 +1,21 @@ +prophesize(DoctrineDbalStore::class); + //$store->configureSchema($schema, Argument::type('Closure'))->shouldBeCalledOnce(); + + $store->configureSchema($schema, Argument::any())->shouldBeCalledOnce()->will( + /** @param array{1: Closure} $args */ + static function (array $args): void { + if (!$args[1]()) { + throw new RuntimeException(); + } + }, + ); + + $adapter = new DoctrineDbalStoreSchemaAdapter($store->reveal()); + $adapter->configureSchema($schema, $this->prophesize(Connection::class)->reveal()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/AggregateRootRegistryTest.php b/tests/Unit/Metadata/Aggregate/AggregateRootRegistryTest.php index 477043f94..9f14db758 100644 --- a/tests/Unit/Metadata/Aggregate/AggregateRootRegistryTest.php +++ b/tests/Unit/Metadata/Aggregate/AggregateRootRegistryTest.php @@ -10,6 +10,7 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile; use PHPUnit\Framework\TestCase; +/** @covers \Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry */ final class AggregateRootRegistryTest extends TestCase { public function testEmpty(): void diff --git a/tests/Unit/Metadata/Aggregate/AttributeAggregateMetadataFactoryTest.php b/tests/Unit/Metadata/Aggregate/AttributeAggregateMetadataFactoryTest.php index 277828614..7bf5f6402 100644 --- a/tests/Unit/Metadata/Aggregate/AttributeAggregateMetadataFactoryTest.php +++ b/tests/Unit/Metadata/Aggregate/AttributeAggregateMetadataFactoryTest.php @@ -21,6 +21,7 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\SplittingEvent; use PHPUnit\Framework\TestCase; +/** @covers \Patchlevel\EventSourcing\Metadata\AggregateRoot\AttributeAggregateRootMetadataFactory */ final class AttributeAggregateMetadataFactoryTest extends TestCase { public function testProfile(): void diff --git a/tests/Unit/Metadata/Aggregate/AttributeAggregateRootRegistryFactoryTest.php b/tests/Unit/Metadata/Aggregate/AttributeAggregateRootRegistryFactoryTest.php index 42fb09e76..2f1744fcb 100644 --- a/tests/Unit/Metadata/Aggregate/AttributeAggregateRootRegistryFactoryTest.php +++ b/tests/Unit/Metadata/Aggregate/AttributeAggregateRootRegistryFactoryTest.php @@ -9,6 +9,7 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile; use PHPUnit\Framework\TestCase; +/** @covers \Patchlevel\EventSourcing\Metadata\AggregateRoot\AttributeAggregateRootRegistryFactory */ final class AttributeAggregateRootRegistryFactoryTest extends TestCase { public function testCreateRegistry(): void diff --git a/tests/Unit/Metadata/ClassFinderTest.php b/tests/Unit/Metadata/ClassFinderTest.php index ba6714232..13a86379b 100644 --- a/tests/Unit/Metadata/ClassFinderTest.php +++ b/tests/Unit/Metadata/ClassFinderTest.php @@ -7,6 +7,7 @@ use Patchlevel\EventSourcing\Metadata\ClassFinder; use PHPUnit\Framework\TestCase; +/** @covers \Patchlevel\EventSourcing\Metadata\ClassFinder */ final class ClassFinderTest extends TestCase { public function testEmpty(): void diff --git a/tests/Unit/Metadata/Event/AttributeEventMetadataFactoryTest.php b/tests/Unit/Metadata/Event/AttributeEventMetadataFactoryTest.php index 0506ec19b..ee5e5b549 100644 --- a/tests/Unit/Metadata/Event/AttributeEventMetadataFactoryTest.php +++ b/tests/Unit/Metadata/Event/AttributeEventMetadataFactoryTest.php @@ -9,6 +9,7 @@ use Patchlevel\EventSourcing\Metadata\Event\ClassIsNotAnEvent; use PHPUnit\Framework\TestCase; +/** @covers \Patchlevel\EventSourcing\Metadata\Event\AttributeEventMetadataFactory */ final class AttributeEventMetadataFactoryTest extends TestCase { public function testEmptyEvent(): void diff --git a/tests/Unit/Metadata/Event/AttributeEventRegistryFactoryTest.php b/tests/Unit/Metadata/Event/AttributeEventRegistryFactoryTest.php index 2b3341dd5..f7f811c1c 100644 --- a/tests/Unit/Metadata/Event/AttributeEventRegistryFactoryTest.php +++ b/tests/Unit/Metadata/Event/AttributeEventRegistryFactoryTest.php @@ -9,6 +9,7 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use PHPUnit\Framework\TestCase; +/** @covers \Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory */ final class AttributeEventRegistryFactoryTest extends TestCase { public function testCreateRegistry(): void diff --git a/tests/Unit/Metadata/Event/EventRegistryTest.php b/tests/Unit/Metadata/Event/EventRegistryTest.php index 2ab296510..f8401d58f 100644 --- a/tests/Unit/Metadata/Event/EventRegistryTest.php +++ b/tests/Unit/Metadata/Event/EventRegistryTest.php @@ -10,6 +10,7 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use PHPUnit\Framework\TestCase; +/** @covers \Patchlevel\EventSourcing\Metadata\Event\EventRegistry */ final class EventRegistryTest extends TestCase { public function testEmpty(): void diff --git a/tests/Unit/Metadata/Projector/AttributeProjectorMetadataFactoryTest.php b/tests/Unit/Metadata/Projector/AttributeProjectorMetadataFactoryTest.php index 2a5e4895d..eb176c345 100644 --- a/tests/Unit/Metadata/Projector/AttributeProjectorMetadataFactoryTest.php +++ b/tests/Unit/Metadata/Projector/AttributeProjectorMetadataFactoryTest.php @@ -16,6 +16,7 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileVisited; use PHPUnit\Framework\TestCase; +/** @covers \Patchlevel\EventSourcing\Metadata\Projector\AttributeProjectorMetadataFactory */ final class AttributeProjectorMetadataFactoryTest extends TestCase { public function testNotAProjection(): void diff --git a/tests/Unit/Outbox/DoctrineOutboxStoreTest.php b/tests/Unit/Outbox/DoctrineOutboxStoreTest.php new file mode 100644 index 000000000..42e26e086 --- /dev/null +++ b/tests/Unit/Outbox/DoctrineOutboxStoreTest.php @@ -0,0 +1,321 @@ +withAggregateClass(Profile::class) + ->withAggregateId('1') + ->withPlayhead(1) + ->withRecordedOn($recordedOn) + ->withNewStreamStart(false) + ->withArchived(false); + + $innerMockedConnection = $this->prophesize(Connection::class); + $innerMockedConnection->insert( + 'outbox', + [ + 'aggregate' => 'profile', + 'aggregate_id' => '1', + 'playhead' => 1, + 'event' => 'profile_created', + 'payload' => '', + 'recorded_on' => $recordedOn, + 'custom_headers' => [], + ], + ['recorded_on' => 'datetimetz_immutable', 'custom_headers' => 'json'], + )->shouldBeCalledOnce(); + + $driver = $this->prophesize(Driver::class); + $driver->connect(Argument::any())->willReturn($innerMockedConnection->reveal()); + + $mockedConnection = $this->prophesize(Connection::class); + $mockedConnection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0]($innerMockedConnection->reveal()) + ); + + $serializer = $this->prophesize(EventSerializer::class); + $serializer->serialize($message->event())->shouldBeCalledOnce()->willReturn(new SerializedEvent('profile_created', '')); + + $doctrineOutboxStore = new DoctrineOutboxStore( + $mockedConnection->reveal(), + $serializer->reveal(), + new AggregateRootRegistry(['profile' => Profile::class]), + ); + + $doctrineOutboxStore->saveOutboxMessage($message); + } + + public function testMarkOutboxMessageConsumed(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withAggregateClass(Profile::class) + ->withAggregateId('1') + ->withPlayhead(1) + ->withRecordedOn($recordedOn) + ->withNewStreamStart(false) + ->withArchived(false); + + $innerMockedConnection = $this->prophesize(Connection::class); + $innerMockedConnection->delete( + 'outbox', + ['aggregate' => 'profile', 'aggregate_id' => '1', 'playhead' => 1], + )->shouldBeCalledOnce(); + + $driver = $this->prophesize(Driver::class); + $driver->connect(Argument::any())->willReturn($innerMockedConnection->reveal()); + + $mockedConnection = $this->prophesize(Connection::class); + $mockedConnection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0]($innerMockedConnection->reveal()) + ); + + $serializer = $this->prophesize(EventSerializer::class); + + $doctrineOutboxStore = new DoctrineOutboxStore( + $mockedConnection->reveal(), + $serializer->reveal(), + new AggregateRootRegistry(['profile' => Profile::class]), + ); + + $doctrineOutboxStore->markOutboxMessageConsumed($message); + } + + public function testCountOutboxMessages(): void + { + $queryBuilder = $this->prophesize(QueryBuilder::class); + $queryBuilder->select('COUNT(*)')->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $queryBuilder->from('outbox')->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $queryBuilder->getSQL()->shouldBeCalledOnce()->willReturn('this sql'); + + $connection = $this->prophesize(Connection::class); + $connection->createQueryBuilder()->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $connection->fetchOne('this sql')->shouldBeCalledOnce()->willReturn('1'); + + $serializer = $this->prophesize(EventSerializer::class); + + $doctrineOutboxStore = new DoctrineOutboxStore( + $connection->reveal(), + $serializer->reveal(), + new AggregateRootRegistry(['profile' => Profile::class]), + ); + + $result = $doctrineOutboxStore->countOutboxMessages(); + self::assertSame(1, $result); + } + + public function testCountOutboxMessagesFailure(): void + { + $queryBuilder = $this->prophesize(QueryBuilder::class); + $queryBuilder->select('COUNT(*)')->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $queryBuilder->from('outbox')->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $queryBuilder->getSQL()->shouldBeCalledOnce()->willReturn('this sql'); + + $connection = $this->prophesize(Connection::class); + $connection->createQueryBuilder()->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $connection->fetchOne('this sql')->shouldBeCalledOnce()->willReturn([]); + + $serializer = $this->prophesize(EventSerializer::class); + + $doctrineOutboxStore = new DoctrineOutboxStore( + $connection->reveal(), + $serializer->reveal(), + new AggregateRootRegistry(['profile' => Profile::class]), + ); + + $this->expectException(WrongQueryResult::class); + $doctrineOutboxStore->countOutboxMessages(); + } + + public function testRetrieveOutboxMessagesNoResult(): void + { + $queryBuilder = $this->prophesize(QueryBuilder::class); + $queryBuilder->select('*')->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $queryBuilder->from('outbox')->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $queryBuilder->setMaxResults(null)->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $queryBuilder->getSQL()->shouldBeCalledOnce()->willReturn('this sql'); + + $connection = $this->prophesize(Connection::class); + $connection->createQueryBuilder()->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $connection->fetchAllAssociative('this sql')->shouldBeCalledOnce()->willReturn([]); + + $platform = $this->prophesize(AbstractPlatform::class); + $connection->getDatabasePlatform()->shouldBeCalledOnce()->willReturn($platform->reveal()); + + $serializer = $this->prophesize(EventSerializer::class); + $serializer->deserialize(Argument::type(SerializedEvent::class))->shouldNotBeCalled(); + + $doctrineOutboxStore = new DoctrineOutboxStore( + $connection->reveal(), + $serializer->reveal(), + new AggregateRootRegistry(['profile' => Profile::class]), + ); + + $messages = $doctrineOutboxStore->retrieveOutboxMessages(); + self::assertSame([], $messages); + } + + public function testRetrieveOutboxMessages(): void + { + $recordedOn = new DateTimeImmutable(); + $event = new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s')); + $message = Message::create($event) + ->withAggregateClass(Profile::class) + ->withAggregateId('1') + ->withPlayhead(1) + ->withRecordedOn($recordedOn) + ->withNewStreamStart(false) + ->withArchived(false); + + $queryBuilder = $this->prophesize(QueryBuilder::class); + $queryBuilder->select('*')->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $queryBuilder->from('outbox')->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $queryBuilder->setMaxResults(null)->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $queryBuilder->getSQL()->shouldBeCalledOnce()->willReturn('this sql'); + + $connection = $this->prophesize(Connection::class); + $connection->createQueryBuilder()->shouldBeCalledOnce()->willReturn($queryBuilder->reveal()); + $connection->fetchAllAssociative('this sql')->shouldBeCalledOnce()->willReturn([ + [ + 'event' => 'profile_created', + 'payload' => '{"profile_id": "1", "email": "s"}', + 'aggregate' => 'profile', + 'aggregate_id' => '1', + 'playhead' => 1, + 'recorded_on' => $recordedOn->format('Y-m-d\TH:i:s.ue'), + 'custom_headers' => '{}', + ], + ]); + + $platform = $this->prophesize(AbstractPlatform::class); + $platform->getDateTimeTzFormatString()->shouldBeCalledOnce()->willReturn('Y-m-d\TH:i:s.ue'); + + $connection->getDatabasePlatform()->shouldBeCalledOnce()->willReturn($platform->reveal()); + + $serializer = $this->prophesize(EventSerializer::class); + $serializer->deserialize(new SerializedEvent('profile_created', '{"profile_id": "1", "email": "s"}'))->shouldBeCalledOnce()->willReturn($event); + + $doctrineOutboxStore = new DoctrineOutboxStore( + $connection->reveal(), + $serializer->reveal(), + new AggregateRootRegistry(['profile' => Profile::class]), + ); + + $messages = $doctrineOutboxStore->retrieveOutboxMessages(); + self::assertEquals([$message], $messages); + } + + public function testConfigureSchema(): void + { + $connection = $this->prophesize(Connection::class); + $serializer = $this->prophesize(EventSerializer::class); + + $doctrineOutboxStore = new DoctrineOutboxStore( + $connection->reveal(), + $serializer->reveal(), + new AggregateRootRegistry(['profile' => Profile::class]), + ); + + $table = $this->prophesize(Table::class); + $column = $this->prophesize(Column::class); + $table->addColumn('aggregate', Types::STRING)->shouldBeCalledOnce() + ->willReturn( + $column + ->setNotnull(true)->shouldBeCalledOnce() + ->getObjectProphecy()->setLength(255)->shouldBeCalledOnce()->willReturn($column->reveal()) + ->getObjectProphecy()->reveal(), + ); + + $column = $this->prophesize(Column::class); + $table->addColumn('aggregate_id', Types::STRING)->shouldBeCalledOnce() + ->willReturn( + $column + ->setNotnull(true)->shouldBeCalledOnce() + ->getObjectProphecy()->setLength(32)->shouldBeCalledOnce()->willReturn($column->reveal()) + ->getObjectProphecy()->reveal(), + ); + + $column = $this->prophesize(Column::class); + $table->addColumn('playhead', Types::INTEGER)->shouldBeCalledOnce() + ->willReturn( + $column + ->setNotnull(true)->shouldBeCalledOnce() + ->getObjectProphecy()->reveal(), + ); + + $column = $this->prophesize(Column::class); + $table->addColumn('event', Types::STRING)->shouldBeCalledOnce() + ->willReturn( + $column + ->setNotnull(true)->shouldBeCalledOnce() + ->getObjectProphecy()->setLength(255)->shouldBeCalledOnce()->willReturn($column->reveal()) + ->getObjectProphecy()->reveal(), + ); + + $column = $this->prophesize(Column::class); + $table->addColumn('payload', Types::JSON)->shouldBeCalledOnce() + ->willReturn( + $column + ->setNotnull(true)->shouldBeCalledOnce() + ->getObjectProphecy()->reveal(), + ); + + $column = $this->prophesize(Column::class); + $table->addColumn('recorded_on', Types::DATETIMETZ_IMMUTABLE)->shouldBeCalledOnce() + ->willReturn( + $column + ->setNotnull(false)->shouldBeCalledOnce() + ->getObjectProphecy()->reveal(), + ); + + $column = $this->prophesize(Column::class); + $table->addColumn('custom_headers', Types::JSON)->shouldBeCalledOnce() + ->willReturn( + $column + ->setNotnull(true)->shouldBeCalledOnce() + ->getObjectProphecy()->reveal(), + ); + + $table->setPrimaryKey(['aggregate', 'aggregate_id', 'playhead'])->shouldBeCalledOnce(); + + $schema = $this->prophesize(Schema::class); + $schema->createTable('outbox')->shouldBeCalledOnce()->willReturn($table->reveal()); + + $doctrineOutboxStore->configureSchema($schema->reveal(), $connection->reveal()); + } +} diff --git a/tests/Unit/Pipeline/Middleware/RecalculatePlayheadMiddlewareTest.php b/tests/Unit/Pipeline/Middleware/RecalculatePlayheadMiddlewareTest.php index fe82c0ea1..921391d6d 100644 --- a/tests/Unit/Pipeline/Middleware/RecalculatePlayheadMiddlewareTest.php +++ b/tests/Unit/Pipeline/Middleware/RecalculatePlayheadMiddlewareTest.php @@ -15,7 +15,7 @@ /** @covers \Patchlevel\EventSourcing\Pipeline\Middleware\RecalculatePlayheadMiddleware */ final class RecalculatePlayheadMiddlewareTest extends TestCase { - public function testReculatePlayhead(): void + public function testRecalculatePlayhead(): void { $middleware = new RecalculatePlayheadMiddleware(); @@ -36,7 +36,7 @@ public function testReculatePlayhead(): void self::assertSame(1, $result[0]->playhead()); } - public function testReculatePlayheadWithSamePlayhead(): void + public function testRecalculatePlayheadWithSamePlayhead(): void { $middleware = new RecalculatePlayheadMiddleware(); @@ -54,4 +54,67 @@ public function testReculatePlayheadWithSamePlayhead(): void self::assertSame([$message], $result); } + + public function testRecalculateMultipleMessages(): void + { + $middleware = new RecalculatePlayheadMiddleware(); + + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hallo@patchlevel.de'), + ); + + $message = Message::create($event) + ->withAggregateClass(Profile::class) + ->withAggregateId('1') + ->withPlayhead(5); + $result = $middleware($message); + + self::assertCount(1, $result); + self::assertSame(Profile::class, $result[0]->aggregateClass()); + self::assertSame(1, $result[0]->playhead()); + + $message = Message::create($event) + ->withAggregateClass(Profile::class) + ->withAggregateId('1') + ->withPlayhead(8); + + $result = $middleware($message); + + self::assertCount(1, $result); + self::assertSame(Profile::class, $result[0]->aggregateClass()); + self::assertSame(2, $result[0]->playhead()); + } + + public function testReset(): void + { + $middleware = new RecalculatePlayheadMiddleware(); + + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hallo@patchlevel.de'), + ); + + $message = Message::create($event) + ->withAggregateClass(Profile::class) + ->withAggregateId('1') + ->withPlayhead(5); + $result = $middleware($message); + + self::assertCount(1, $result); + self::assertSame(Profile::class, $result[0]->aggregateClass()); + self::assertSame(1, $result[0]->playhead()); + + $message = Message::create($event) + ->withAggregateClass(Profile::class) + ->withAggregateId('1') + ->withPlayhead(8); + + $middleware->reset(); + $result = $middleware($message); + + self::assertCount(1, $result); + self::assertSame(Profile::class, $result[0]->aggregateClass()); + self::assertSame(1, $result[0]->playhead()); + } } diff --git a/tests/Unit/Pipeline/PipelineTest.php b/tests/Unit/Pipeline/PipelineTest.php index 8a830f768..8eccf29f0 100644 --- a/tests/Unit/Pipeline/PipelineTest.php +++ b/tests/Unit/Pipeline/PipelineTest.php @@ -35,6 +35,31 @@ public function testPipeline(): void self::assertSame($messages, $target->messages()); } + public function testPipelineWithObserver(): void + { + $messages = $this->messages(); + + $source = new InMemorySource($messages); + $target = new InMemoryTarget(); + $pipeline = new Pipeline($source, $target); + + self::assertSame(5, $pipeline->count()); + + $observer = new class { + public bool $called = false; + + public function __invoke(Message $message): void + { + $this->called = true; + } + }; + + $pipeline->run($observer->__invoke(...)); + + self::assertSame($messages, $target->messages()); + self::assertTrue($observer->called); + } + public function testPipelineWithMiddleware(): void { $messages = $this->messages(); diff --git a/tests/Unit/Pipeline/Source/InMemorySourceTest.php b/tests/Unit/Pipeline/Source/InMemorySourceTest.php index 94d2f7001..ce4ff5bc0 100644 --- a/tests/Unit/Pipeline/Source/InMemorySourceTest.php +++ b/tests/Unit/Pipeline/Source/InMemorySourceTest.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Pipeline\Source; +use ArrayIterator; use Patchlevel\EventSourcing\EventBus\Message; use Patchlevel\EventSourcing\Pipeline\Source\InMemorySource; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email; @@ -41,4 +42,15 @@ public function testCount(): void self::assertSame(1, $source->count()); } + + public function testCountWithIterator(): void + { + $message = new Message( + new ProfileCreated(ProfileId::fromString('1'), Email::fromString('foo@test.com')), + ); + + $source = new InMemorySource(new ArrayIterator([$message])); + + self::assertSame(1, $source->count()); + } } diff --git a/tests/Unit/Pipeline/Target/ProjectorRepositoryTargetTest.php b/tests/Unit/Pipeline/Target/ProjectorRepositoryTargetTest.php new file mode 100644 index 000000000..ca5b7ac6f --- /dev/null +++ b/tests/Unit/Pipeline/Target/ProjectorRepositoryTargetTest.php @@ -0,0 +1,75 @@ +message = $message; + } + }; + + $projectorRepository = $this->prophesize(ProjectorRepository::class); + $projectorRepository->projectors()->shouldBeCalledOnce()->willReturn([$projector]); + + $projectorResolver = $this->prophesize(ProjectorResolver::class); + $projectorResolver->resolveSubscribeMethod($projector, $message)->shouldBeCalledOnce()->willReturn($projector(...)); + + $projectorRepositoryTarget = new ProjectorRepositoryTarget($projectorRepository->reveal(), $projectorResolver->reveal()); + $projectorRepositoryTarget->save($message); + + self::assertSame($message, $projector->message); + } + + public function testSaveNoHit(): void + { + $message = new Message( + new ProfileCreated(ProfileId::fromString('1'), Email::fromString('foo@test.com')), + ); + + $projector = new class { + public Message|null $message = null; + + public function __invoke(Message $message): void + { + $this->message = $message; + } + }; + + $projectorRepository = $this->prophesize(ProjectorRepository::class); + $projectorRepository->projectors()->shouldBeCalledOnce()->willReturn([$projector]); + + $projectorResolver = $this->prophesize(ProjectorResolver::class); + $projectorResolver->resolveSubscribeMethod($projector, $message)->shouldBeCalledOnce()->willReturn(null); + + $projectorRepositoryTarget = new ProjectorRepositoryTarget($projectorRepository->reveal(), $projectorResolver->reveal()); + $projectorRepositoryTarget->save($message); + + self::assertNull($projector->message); + } +} diff --git a/tests/Unit/Pipeline/Target/ProjectorTargetTest.php b/tests/Unit/Pipeline/Target/ProjectorTargetTest.php new file mode 100644 index 000000000..20e86f78e --- /dev/null +++ b/tests/Unit/Pipeline/Target/ProjectorTargetTest.php @@ -0,0 +1,68 @@ +message = $message; + } + }; + + $projectorResolver = $this->prophesize(ProjectorResolver::class); + $projectorResolver->resolveSubscribeMethod($projector, $message)->shouldBeCalledOnce()->willReturn($projector(...)); + + $projectorTarget = new ProjectorTarget($projector, $projectorResolver->reveal()); + $projectorTarget->save($message); + + self::assertSame($message, $projector->message); + } + + public function testSaveNoHit(): void + { + $message = new Message( + new ProfileCreated(ProfileId::fromString('1'), Email::fromString('foo@test.com')), + ); + + $projector = new class { + public Message|null $message = null; + + public function __invoke(Message $message): void + { + $this->message = $message; + } + }; + + $projectorResolver = $this->prophesize(ProjectorResolver::class); + $projectorResolver->resolveSubscribeMethod($projector, $message)->shouldBeCalledOnce()->willReturn(null); + + $projectorTarget = new ProjectorTarget($projector, $projectorResolver->reveal()); + $projectorTarget->save($message); + + self::assertNull($projector->message); + } +} diff --git a/tests/Unit/Projection/Projection/DuplicateProjectionIdTest.php b/tests/Unit/Projection/Projection/DuplicateProjectionIdTest.php new file mode 100644 index 000000000..170435af3 --- /dev/null +++ b/tests/Unit/Projection/Projection/DuplicateProjectionIdTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Projection/Projection/ProjectionCollectionTest.php b/tests/Unit/Projection/Projection/ProjectionCollectionTest.php index e31db42c9..bc428e4dd 100644 --- a/tests/Unit/Projection/Projection/ProjectionCollectionTest.php +++ b/tests/Unit/Projection/Projection/ProjectionCollectionTest.php @@ -202,4 +202,16 @@ public function testFilterByCriteriaWithIds(): void self::assertFalse($newCollection->has($barId)); self::assertSame(1, $newCollection->count()); } + + public function testIterator(): void + { + $id = new ProjectionId('test', 1); + $projection = new Projection($id); + $collection = new ProjectionCollection([$projection]); + + $iterator = $collection->getIterator(); + + self::assertSame($projection, $iterator->current()); + self::assertSame(1, $iterator->count()); + } } diff --git a/tests/Unit/Projection/Projection/ProjectionErrorTest.php b/tests/Unit/Projection/Projection/ProjectionErrorTest.php new file mode 100644 index 000000000..24b615924 --- /dev/null +++ b/tests/Unit/Projection/Projection/ProjectionErrorTest.php @@ -0,0 +1,21 @@ +errorMessage); + self::assertIsArray($error->errorContext); + } +} diff --git a/tests/Unit/Projection/Projection/ProjectionNotFoundTest.php b/tests/Unit/Projection/Projection/ProjectionNotFoundTest.php new file mode 100644 index 000000000..554d5cd3c --- /dev/null +++ b/tests/Unit/Projection/Projection/ProjectionNotFoundTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Projection/Projection/ProjectionTest.php b/tests/Unit/Projection/Projection/ProjectionTest.php index c714b3ecd..137b6f61c 100644 --- a/tests/Unit/Projection/Projection/ProjectionTest.php +++ b/tests/Unit/Projection/Projection/ProjectionTest.php @@ -113,4 +113,25 @@ public function testChangePosition(): void self::assertEquals(10, $projection->position()); } + + public function testRetry(): void + { + $projection = new Projection( + new ProjectionId('test', 1), + ); + + self::assertEquals(0, $projection->retry()); + self::assertFalse($projection->isRetryDisallowed()); + + $projection->incrementRetry(); + self::assertEquals(1, $projection->retry()); + + $projection->disallowRetry(); + self::assertEquals(-1, $projection->retry()); + self::assertTrue($projection->isRetryDisallowed()); + + $projection->resetRetry(); + self::assertEquals(0, $projection->retry()); + self::assertFalse($projection->isRetryDisallowed()); + } } diff --git a/tests/Unit/Serializer/DefaultEventSerializerTest.php b/tests/Unit/Serializer/DefaultEventSerializerTest.php index 2e5af617a..1112660fb 100644 --- a/tests/Unit/Serializer/DefaultEventSerializerTest.php +++ b/tests/Unit/Serializer/DefaultEventSerializerTest.php @@ -17,6 +17,7 @@ use Patchlevel\Hydrator\MetadataHydrator; use PHPUnit\Framework\TestCase; +/** @covers \Patchlevel\EventSourcing\Serializer\DefaultEventSerializer */ final class DefaultEventSerializerTest extends TestCase { private DefaultEventSerializer $serializer; diff --git a/tests/Unit/Serializer/Encoder/DecodeNotPossibleTest.php b/tests/Unit/Serializer/Encoder/DecodeNotPossibleTest.php new file mode 100644 index 000000000..836e39404 --- /dev/null +++ b/tests/Unit/Serializer/Encoder/DecodeNotPossibleTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame('foo', $exception->data()); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Serializer/Encoder/EncodeNotPossibleTest.php b/tests/Unit/Serializer/Encoder/EncodeNotPossibleTest.php new file mode 100644 index 000000000..4fde86908 --- /dev/null +++ b/tests/Unit/Serializer/Encoder/EncodeNotPossibleTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame([], $exception->data()); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Serializer/Encoder/JsonEncoderTest.php b/tests/Unit/Serializer/Encoder/JsonEncoderTest.php index 85af65424..c77f882d7 100644 --- a/tests/Unit/Serializer/Encoder/JsonEncoderTest.php +++ b/tests/Unit/Serializer/Encoder/JsonEncoderTest.php @@ -5,11 +5,13 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Serializer\Encoder; use Patchlevel\EventSourcing\Serializer\Encoder\DecodeNotPossible; +use Patchlevel\EventSourcing\Serializer\Encoder\EncodeNotPossible; use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; use PHPUnit\Framework\TestCase; +/** @covers \Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder */ final class JsonEncoderTest extends TestCase { private JsonEncoder $encoder; @@ -32,6 +34,31 @@ public function testEncode(): void ); } + public function testEncodePrettify(): void + { + $data = [ + 'profileId' => '1', + 'email' => 'info@patchlevel.de', + ]; + self::assertEquals( + '{ + "profileId": "1", + "email": "info@patchlevel.de" +}', + $this->encoder->encode($data, [JsonEncoder::OPTION_PRETTY_PRINT => true]), + ); + } + + public function testEncodeError(): void + { + $data = [ + 'foo' => ["\xF4\xBF\xBF\xBF̆" => 1], + ]; + + $this->expectException(EncodeNotPossible::class); + $this->encoder->encode($data); + } + public function testEncodeNotNormalizedData(): void { $data = [ diff --git a/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php b/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php index 8a24d8f07..6f21c10c1 100644 --- a/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php +++ b/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php @@ -13,6 +13,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Ramsey\Uuid\Exception\InvalidUuidStringException; +/** @covers \Patchlevel\EventSourcing\Serializer\Normalizer\IdNormalizer */ #[Attribute(Attribute::TARGET_PROPERTY)] final class IdNormalizerTest extends TestCase { @@ -58,4 +59,12 @@ public function testDenormalizeWithValue(): void $normalizer = new IdNormalizer(CustomId::class); $this->assertEquals(new CustomId('foo'), $normalizer->denormalize('foo')); } + + public function testDenormalizeWithWrongValue(): void + { + $normalizer = new IdNormalizer(CustomId::class); + + $this->expectException(InvalidArgument::class); + $normalizer->denormalize(123); + } } diff --git a/tests/Unit/Serializer/Upcast/UpcasterChainTest.php b/tests/Unit/Serializer/Upcast/UpcasterChainTest.php index 45637a62a..f2f26ec9f 100644 --- a/tests/Unit/Serializer/Upcast/UpcasterChainTest.php +++ b/tests/Unit/Serializer/Upcast/UpcasterChainTest.php @@ -9,6 +9,7 @@ use Patchlevel\EventSourcing\Serializer\Upcast\UpcasterChain; use PHPUnit\Framework\TestCase; +/** @covers \Patchlevel\EventSourcing\Serializer\Upcast\UpcasterChain */ final class UpcasterChainTest extends TestCase { public function testChainSuccessful(): void diff --git a/tests/Unit/Snapshot/Adapter/InMemorySnapshotAdapterTest.php b/tests/Unit/Snapshot/Adapter/InMemorySnapshotAdapterTest.php index a79664705..a89905ea2 100644 --- a/tests/Unit/Snapshot/Adapter/InMemorySnapshotAdapterTest.php +++ b/tests/Unit/Snapshot/Adapter/InMemorySnapshotAdapterTest.php @@ -27,4 +27,16 @@ public function testSnapshotNotFound(): void $store = new InMemorySnapshotAdapter(); $store->load('baz'); } + + public function testClear(): void + { + $store = new InMemorySnapshotAdapter(); + $store->save('baz', ['foo' => 'bar']); + + self::assertSame(['foo' => 'bar'], $store->load('baz')); + $store->clear(); + + $this->expectException(SnapshotNotFound::class); + $store->load('baz'); + } } diff --git a/tests/Unit/Snapshot/Adapter/Psr16SnapshotAdapterTest.php b/tests/Unit/Snapshot/Adapter/Psr16SnapshotAdapterTest.php index 443defaf2..6871bfab1 100644 --- a/tests/Unit/Snapshot/Adapter/Psr16SnapshotAdapterTest.php +++ b/tests/Unit/Snapshot/Adapter/Psr16SnapshotAdapterTest.php @@ -10,7 +10,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Psr\SimpleCache\CacheInterface; -/** @covers \Patchlevel\EventSourcing\Snapshot\Adapter\Psr16SnapshotAdapter */ +/** @covers \Patchlevel\EventSourcing\Snapshot\Adapter\Psr16SnapshotAdapter */ final class Psr16SnapshotAdapterTest extends TestCase { use ProphecyTrait; diff --git a/tests/Unit/Snapshot/Adapter/SnapshotNotFoundTest.php b/tests/Unit/Snapshot/Adapter/SnapshotNotFoundTest.php new file mode 100644 index 000000000..98d4f322a --- /dev/null +++ b/tests/Unit/Snapshot/Adapter/SnapshotNotFoundTest.php @@ -0,0 +1,23 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Snapshot/AdapterNotFoundTest.php b/tests/Unit/Snapshot/AdapterNotFoundTest.php new file mode 100644 index 000000000..0b17c0d81 --- /dev/null +++ b/tests/Unit/Snapshot/AdapterNotFoundTest.php @@ -0,0 +1,23 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Snapshot/DefaultSnapshotStoreTest.php b/tests/Unit/Snapshot/DefaultSnapshotStoreTest.php index 9ba4fb179..89db921d3 100644 --- a/tests/Unit/Snapshot/DefaultSnapshotStoreTest.php +++ b/tests/Unit/Snapshot/DefaultSnapshotStoreTest.php @@ -7,12 +7,16 @@ use Patchlevel\EventSourcing\Snapshot\Adapter\SnapshotAdapter; use Patchlevel\EventSourcing\Snapshot\AdapterNotFound; use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; +use Patchlevel\EventSourcing\Snapshot\SnapshotNotConfigured; +use Patchlevel\EventSourcing\Snapshot\SnapshotNotFound; use Patchlevel\EventSourcing\Snapshot\SnapshotVersionInvalid; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithSnapshot; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; +use RuntimeException; /** @covers \Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore */ final class DefaultSnapshotStoreTest extends TestCase @@ -63,6 +67,19 @@ public function testLoad(): void self::assertEquals(2, $aggregate->playhead()); } + public function testLoadNotFound(): void + { + $adapter = $this->prophesize(SnapshotAdapter::class); + $adapter->load( + 'profile_with_snapshot-1', + )->willThrow(RuntimeException::class); + + $store = new DefaultSnapshotStore(['memory' => $adapter->reveal()]); + + $this->expectException(SnapshotNotFound::class); + $store->load(ProfileWithSnapshot::class, ProfileId::fromString('1')); + } + public function testLoadLegacySnapshots(): void { $this->expectException(SnapshotVersionInvalid::class); @@ -104,6 +121,14 @@ public function testAdapterIsMissing(): void $store->load(ProfileWithSnapshot::class, ProfileId::fromString('1')); } + public function testSnapshotConfigIsMissing(): void + { + $this->expectException(SnapshotNotConfigured::class); + + $store = new DefaultSnapshotStore([], null); + $store->load(Profile::class, ProfileId::fromString('1')); + } + public function testGetAdapter(): void { $adapter = $this->prophesize(SnapshotAdapter::class)->reveal(); diff --git a/tests/Unit/Snapshot/SnapshotNotConfiguredTest.php b/tests/Unit/Snapshot/SnapshotNotConfiguredTest.php new file mode 100644 index 000000000..3a43ae7b4 --- /dev/null +++ b/tests/Unit/Snapshot/SnapshotNotConfiguredTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Snapshot/SnapshotNotFoundTest.php b/tests/Unit/Snapshot/SnapshotNotFoundTest.php new file mode 100644 index 000000000..ee387fed7 --- /dev/null +++ b/tests/Unit/Snapshot/SnapshotNotFoundTest.php @@ -0,0 +1,28 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Snapshot/SnapshotVersionInvalidTest.php b/tests/Unit/Snapshot/SnapshotVersionInvalidTest.php new file mode 100644 index 000000000..a2ceb8bf3 --- /dev/null +++ b/tests/Unit/Snapshot/SnapshotVersionInvalidTest.php @@ -0,0 +1,23 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +}