diff --git a/composer.json b/composer.json index 30ef0ccc7..d08321907 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "doctrine/dbal": "^4.4.0", "doctrine/migrations": "^3.3.2", - "patchlevel/hydrator": "^1.8.0", + "patchlevel/hydrator": "^1.24.0", "patchlevel/worker": "^1.4.0", "psr/cache": "^2.0.0 || ^3.0.0", "psr/clock": "^1.0", diff --git a/composer.lock b/composer.lock index 21a4f4e7d..871ffa04d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8cf3383e3c323c67f6456b7cb8e765fd", + "content-hash": "0e9502d1c80c844be7148084542ae739", "packages": [ { "name": "brick/math", diff --git a/infection.json.dist b/infection.json.dist index 55c3b7002..00d9c8dfa 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -16,7 +16,7 @@ "mutators": { "@default": true }, - "minMsi": 69, - "minCoveredMsi": 86, + "minMsi": 90, + "minCoveredMsi": 90, "testFrameworkOptions": "--testsuite=unit" } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b463caf87..3e22d7980 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -306,54 +306,6 @@ parameters: count: 1 path: tests/Benchmark/BasicImplementation/Projection/ProfileProjector.php - - - message: '#^Call to an undefined method Patchlevel\\EventSourcing\\Subscription\\Engine\\SubscriptionEngine\:\:boot\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/Benchmark/NoopSubscriptionEngineBench.php - - - - message: '#^Call to an undefined method Patchlevel\\EventSourcing\\Subscription\\Engine\\SubscriptionEngine\:\:remove\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/Benchmark/NoopSubscriptionEngineBench.php - - - - message: '#^Call to an undefined method Patchlevel\\EventSourcing\\Subscription\\Engine\\SubscriptionEngine\:\:setup\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/Benchmark/NoopSubscriptionEngineBench.php - - - - message: '#^Instantiated class Patchlevel\\EventSourcing\\Store\\DoctrineDbalStore not found\.$#' - identifier: class.notFound - count: 1 - path: tests/Benchmark/NoopSubscriptionEngineBench.php - - - - message: '#^Parameter \#1 \$schemaConfigurator of class Patchlevel\\EventSourcing\\Schema\\ChainDoctrineSchemaConfigurator constructor expects iterable\, array\ given\.$#' - identifier: argument.type - count: 1 - path: tests/Benchmark/NoopSubscriptionEngineBench.php - - - - message: '#^Property Patchlevel\\EventSourcing\\Tests\\Benchmark\\NoopSubscriptionEngineBench\:\:\$id \(Patchlevel\\EventSourcing\\Aggregate\\AggregateRootId\) does not accept Patchlevel\\EventSourcing\\Tests\\Benchmark\\BasicImplementation\\ProfileId\.$#' - identifier: assign.propertyType - count: 1 - path: tests/Benchmark/NoopSubscriptionEngineBench.php - - - - message: '#^Property Patchlevel\\EventSourcing\\Tests\\Benchmark\\NoopSubscriptionEngineBench\:\:\$id has unknown class Patchlevel\\EventSourcing\\Aggregate\\AggregateRootId as its type\.$#' - identifier: class.notFound - count: 1 - path: tests/Benchmark/NoopSubscriptionEngineBench.php - - - - message: '#^Property Patchlevel\\EventSourcing\\Tests\\Benchmark\\NoopSubscriptionEngineBench\:\:\$store \(Patchlevel\\EventSourcing\\Store\\Store\) does not accept Patchlevel\\EventSourcing\\Store\\DoctrineDbalStore\.$#' - identifier: assign.propertyType - count: 1 - path: tests/Benchmark/NoopSubscriptionEngineBench.php - - message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Tests\\\\Integration\\\\BankAccountSplitStream\\\\BankAccount'' and Patchlevel\\EventSourcing\\Tests\\Integration\\BankAccountSplitStream\\BankAccount will always evaluate to true\.$#' identifier: staticMethod.alreadyNarrowedType @@ -522,6 +474,18 @@ parameters: count: 1 path: tests/Unit/Fixture/ProfileInvalid.php + - + message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\BrokenAutoInitializableProfile\:\:\$id is unused\.$#' + identifier: property.unused + count: 1 + path: tests/Unit/Fixture/BrokenAutoInitializableProfile.php + + - + message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\ProfileWithAggregateStream\:\:\$id is unused\.$#' + identifier: property.unused + count: 1 + path: tests/Unit/Fixture/ProfileWithAggregateStream.php + - message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\ProfileWithBrokenApplyBothUsage\:\:\$id is unused\.$#' identifier: property.unused @@ -552,12 +516,30 @@ parameters: count: 1 path: tests/Unit/Fixture/ProfileWithBrokenApplyNoType.php + - + message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\ProfileWithBrokenApplyStringType\:\:\$id is unused\.$#' + identifier: property.unused + count: 1 + path: tests/Unit/Fixture/ProfileWithBrokenApplyStringType.php + + - + message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\ProfileWithBrokenApplyUnionIntersection\:\:\$id is unused\.$#' + identifier: property.unused + count: 1 + path: tests/Unit/Fixture/ProfileWithBrokenApplyUnionIntersection.php + - message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\ProfileWithBrokenId\:\:\$id is never read, only written\.$#' identifier: property.onlyWritten count: 1 path: tests/Unit/Fixture/ProfileWithBrokenId.php + - + message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\ProfileWithDuplicateApply\:\:\$id is unused\.$#' + identifier: property.unused + count: 1 + path: tests/Unit/Fixture/ProfileWithDuplicateApply.php + - message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\ProfileWithEmptyApply\:\:\$id is unused\.$#' identifier: property.unused @@ -582,6 +564,12 @@ parameters: count: 1 path: tests/Unit/Fixture/ProfileWithSuppressAll.php + - + message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\ProfileWithoutAggregateAttribute\:\:\$id is unused\.$#' + identifier: property.unused + count: 1 + path: tests/Unit/Fixture/ProfileWithoutAggregateAttribute.php + - message: '#^Parameter \#2 \$callable of class Patchlevel\\EventSourcing\\Message\\Translator\\ReplaceEventTranslator constructor expects callable\(Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\MessagePublished\)\: object, Closure\(Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\ProfileCreated\)\: Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\ProfileVisited given\.$#' identifier: argument.type @@ -589,7 +577,7 @@ parameters: path: tests/Unit/Message/Translator/ReplaceEventTranslatorTest.php - - message: '#^Method class@anonymous/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest\.php\:278\:\:profileVisited\(\) has parameter \$message with no type specified\.$#' + message: '#^Method class@anonymous/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest\.php\:284\:\:profileVisited\(\) has parameter \$message with no type specified\.$#' identifier: missingType.parameter count: 1 path: tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php diff --git a/src/Console/Command/SubscriptionRemoveCommand.php b/src/Console/Command/SubscriptionRemoveCommand.php index c204fd9d2..7143b3748 100644 --- a/src/Console/Command/SubscriptionRemoveCommand.php +++ b/src/Console/Command/SubscriptionRemoveCommand.php @@ -20,6 +20,8 @@ final class SubscriptionRemoveCommand extends SubscriptionCommand { protected function configure(): void { + parent::configure(); + $this ->addOption('force', 'f', InputOption::VALUE_NONE, 'Set this parameter to execute this action without prompting'); } diff --git a/src/Serializer/AttributeEventTagExtractor.php b/src/Serializer/AttributeEventTagExtractor.php index 388346d95..8baf74ed6 100644 --- a/src/Serializer/AttributeEventTagExtractor.php +++ b/src/Serializer/AttributeEventTagExtractor.php @@ -10,9 +10,11 @@ use Stringable; use function array_keys; +use function array_map; use function hash; use function is_int; use function is_string; +use function strval; /** @experimental */ final class AttributeEventTagExtractor implements EventTagExtractor @@ -63,6 +65,6 @@ public function extract(object $event): array $tags[$value] = true; } - return array_keys($tags); + return array_map(strval(...), array_keys($tags)); } } diff --git a/tests/Unit/Aggregate/InvalidAggregateStreamNameTest.php b/tests/Unit/Aggregate/InvalidAggregateStreamNameTest.php new file mode 100644 index 000000000..9c7c0d613 --- /dev/null +++ b/tests/Unit/Aggregate/InvalidAggregateStreamNameTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Attribute/AnswerTest.php b/tests/Unit/Attribute/AnswerTest.php new file mode 100644 index 000000000..df8bfabd1 --- /dev/null +++ b/tests/Unit/Attribute/AnswerTest.php @@ -0,0 +1,28 @@ +queryClass); + } + + public function testInstantiateWithDefaults(): void + { + $attribute = new Answer(); + + self::assertNull($attribute->queryClass); + } +} diff --git a/tests/Unit/Attribute/BatchFlushTest.php b/tests/Unit/Attribute/BatchFlushTest.php new file mode 100644 index 000000000..6922d4668 --- /dev/null +++ b/tests/Unit/Attribute/BatchFlushTest.php @@ -0,0 +1,27 @@ +afterMessages); + } + + public function testInstantiateWithDefaults(): void + { + $attribute = new BatchFlush(); + + self::assertNull($attribute->afterMessages); + } +} diff --git a/tests/Unit/Attribute/HandleTest.php b/tests/Unit/Attribute/HandleTest.php new file mode 100644 index 000000000..102659e92 --- /dev/null +++ b/tests/Unit/Attribute/HandleTest.php @@ -0,0 +1,28 @@ +commandClass); + } + + public function testInstantiateWithDefaults(): void + { + $attribute = new Handle(); + + self::assertNull($attribute->commandClass); + } +} diff --git a/tests/Unit/Attribute/InjectTest.php b/tests/Unit/Attribute/InjectTest.php new file mode 100644 index 000000000..3cc6354d5 --- /dev/null +++ b/tests/Unit/Attribute/InjectTest.php @@ -0,0 +1,20 @@ +service); + } +} diff --git a/tests/Unit/Attribute/ProcessorTest.php b/tests/Unit/Attribute/ProcessorTest.php new file mode 100644 index 000000000..b0eab6145 --- /dev/null +++ b/tests/Unit/Attribute/ProcessorTest.php @@ -0,0 +1,32 @@ +id); + self::assertSame('bar', $attribute->group); + self::assertSame(RunMode::FromBeginning, $attribute->runMode); + } + + public function testInstantiateWithDefaults(): void + { + $attribute = new Processor('foo'); + + self::assertSame('foo', $attribute->id); + self::assertSame('processor', $attribute->group); + self::assertSame(RunMode::FromNow, $attribute->runMode); + } +} diff --git a/tests/Unit/Attribute/ProjectorTest.php b/tests/Unit/Attribute/ProjectorTest.php new file mode 100644 index 000000000..6e4e2f86d --- /dev/null +++ b/tests/Unit/Attribute/ProjectorTest.php @@ -0,0 +1,32 @@ +id); + self::assertSame('bar', $attribute->group); + self::assertSame(RunMode::Once, $attribute->runMode); + } + + public function testInstantiateWithDefaults(): void + { + $attribute = new Projector('foo'); + + self::assertSame('foo', $attribute->id); + self::assertSame('projector', $attribute->group); + self::assertSame(RunMode::FromBeginning, $attribute->runMode); + } +} diff --git a/tests/Unit/Attribute/RetryStrategyTest.php b/tests/Unit/Attribute/RetryStrategyTest.php new file mode 100644 index 000000000..446c0b586 --- /dev/null +++ b/tests/Unit/Attribute/RetryStrategyTest.php @@ -0,0 +1,20 @@ +name); + } +} diff --git a/tests/Unit/Attribute/SharedApplyContextTest.php b/tests/Unit/Attribute/SharedApplyContextTest.php new file mode 100644 index 000000000..dee8a5391 --- /dev/null +++ b/tests/Unit/Attribute/SharedApplyContextTest.php @@ -0,0 +1,21 @@ +aggregates); + } +} diff --git a/tests/Unit/Attribute/StreamTest.php b/tests/Unit/Attribute/StreamTest.php new file mode 100644 index 000000000..a198ac8e7 --- /dev/null +++ b/tests/Unit/Attribute/StreamTest.php @@ -0,0 +1,20 @@ +name); + } +} diff --git a/tests/Unit/CommandBus/Handler/AggregateIdNotFoundTest.php b/tests/Unit/CommandBus/Handler/AggregateIdNotFoundTest.php new file mode 100644 index 000000000..f087a31d2 --- /dev/null +++ b/tests/Unit/CommandBus/Handler/AggregateIdNotFoundTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/CommandBus/Handler/DefaultParameterResolverTest.php b/tests/Unit/CommandBus/Handler/DefaultParameterResolverTest.php index 7f2cad66d..32d8265b4 100644 --- a/tests/Unit/CommandBus/Handler/DefaultParameterResolverTest.php +++ b/tests/Unit/CommandBus/Handler/DefaultParameterResolverTest.php @@ -113,6 +113,35 @@ public function handle(stdClass $command, mixed $foo): void self::assertSame([$command], $result); } + public function testMissingTypeOnParameter(): void + { + $this->expectException(ServiceNotResolvable::class); + + $class = new class () { + // phpcs:disable + /** @phpstan-ignore-next-line */ + public function handle(stdClass $command, $foo): void + { + } + // phpcs:enable + }; + + $container = $this->createMock(ContainerInterface::class); + + $resolver = new DefaultParameterResolver($container); + + $command = new stdClass(); + + $result = [ + ...$resolver->resolve( + new ReflectionMethod($class, 'handle'), + $command, + ), + ]; + + self::assertSame([$command], $result); + } + public function testNoClass(): void { $this->expectException(ServiceNotResolvable::class); diff --git a/tests/Unit/CommandBus/Handler/ServiceNotResolvableTest.php b/tests/Unit/CommandBus/Handler/ServiceNotResolvableTest.php new file mode 100644 index 000000000..3001cd483 --- /dev/null +++ b/tests/Unit/CommandBus/Handler/ServiceNotResolvableTest.php @@ -0,0 +1,61 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testTypeNotObject(): void + { + $exception = ServiceNotResolvable::typeNotObject(Profile::class, 'foo'); + + self::assertSame( + sprintf('Type for property "foo" in class "%s" must be object', Profile::class), + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testMissingContainer(): void + { + $exception = ServiceNotResolvable::missingContainer(); + + self::assertSame('Container is not configured', $exception->getMessage()); + self::assertSame(0, $exception->getCode()); + } + + public function testMissingService(): void + { + $previous = new RuntimeException('service error'); + + $exception = ServiceNotResolvable::missingService(Profile::class, 'handle', 'foo', $previous); + + self::assertSame( + sprintf('Missing service for parameter "foo" in "%s::handle" . Exception: service error', Profile::class), + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + self::assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/Unit/CommandBus/Handler/UpdateAggregateHandlerTest.php b/tests/Unit/CommandBus/Handler/UpdateAggregateHandlerTest.php index 28e3138e8..384b0f707 100644 --- a/tests/Unit/CommandBus/Handler/UpdateAggregateHandlerTest.php +++ b/tests/Unit/CommandBus/Handler/UpdateAggregateHandlerTest.php @@ -4,6 +4,8 @@ namespace Patchlevel\EventSourcing\Tests\Unit\CommandBus\Handler; +use InvalidArgumentException; +use Patchlevel\EventSourcing\Attribute\Id; use Patchlevel\EventSourcing\CommandBus\Handler\AggregateIdNotFound; use Patchlevel\EventSourcing\CommandBus\Handler\DefaultParameterResolver; use Patchlevel\EventSourcing\CommandBus\Handler\UpdateAggregateHandler; @@ -70,4 +72,29 @@ public function testMissingAggregateId(): void $handler->__invoke($command); } + + public function testIdPropertyNotIdentifier(): void + { + $repositoryManager = $this->createMock(RepositoryManager::class); + + $handler = new UpdateAggregateHandler( + $repositoryManager, + ProfileWithHandler::class, + 'changeName', + new DefaultParameterResolver(), + ); + + $command = new class ('123') { + public function __construct( + #[Id] + public readonly string $profileId, + ) { + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Id property must be an instance of AggregateRootId'); + + $handler->__invoke($command); + } } diff --git a/tests/Unit/CommandBus/HandlerFinderTest.php b/tests/Unit/CommandBus/HandlerFinderTest.php index 5b11014d0..c7a112b04 100644 --- a/tests/Unit/CommandBus/HandlerFinderTest.php +++ b/tests/Unit/CommandBus/HandlerFinderTest.php @@ -141,4 +141,52 @@ public function handle(CreateProfile $command): void new HandlerReference(ChangeProfileName::class, 'handle', false), ], $result); } + + public function testSkipMethodWithoutHandleAttribute(): void + { + $class = new class () { + public function handle(CreateProfile $command): void + { + } + }; + + $result = [...HandlerFinder::findInClass($class::class)]; + + self::assertSame([], $result); + } + + public function testMissingType(): void + { + $this->expectException(InvalidHandleMethod::class); + + $class = new class () { + // phpcs:disable + /** @phpstan-ignore-next-line */ + #[Handle] + public function handle($command): void + { + } + // phpcs:enable + }; + + $result = [...HandlerFinder::findInClass($class::class)]; + + self::assertSame([], $result); + } + + public function testUnionWithNotObjectType(): void + { + $this->expectException(InvalidHandleMethod::class); + + $class = new class () { + #[Handle] + public function handle(CreateProfile|string $command): void + { + } + }; + + $result = [...HandlerFinder::findInClass($class::class)]; + + self::assertSame([], $result); + } } diff --git a/tests/Unit/CommandBus/HandlerNotFoundTest.php b/tests/Unit/CommandBus/HandlerNotFoundTest.php new file mode 100644 index 000000000..be123a3ce --- /dev/null +++ b/tests/Unit/CommandBus/HandlerNotFoundTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/CommandBus/HandlerReferenceTest.php b/tests/Unit/CommandBus/HandlerReferenceTest.php new file mode 100644 index 000000000..91fdfca63 --- /dev/null +++ b/tests/Unit/CommandBus/HandlerReferenceTest.php @@ -0,0 +1,23 @@ +commandClass); + self::assertSame('handle', $reference->method); + self::assertTrue($reference->static); + } +} diff --git a/tests/Unit/CommandBus/InvalidHandleMethodTest.php b/tests/Unit/CommandBus/InvalidHandleMethodTest.php new file mode 100644 index 000000000..85a497951 --- /dev/null +++ b/tests/Unit/CommandBus/InvalidHandleMethodTest.php @@ -0,0 +1,38 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testIncompatibleType(): void + { + $exception = InvalidHandleMethod::incompatibleType(Profile::class, 'handle'); + + self::assertSame( + sprintf('Method "handle" in aggregate "%s" has no compatible type', Profile::class), + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/CommandBus/MultipleHandlersFoundTest.php b/tests/Unit/CommandBus/MultipleHandlersFoundTest.php new file mode 100644 index 000000000..c4dee984e --- /dev/null +++ b/tests/Unit/CommandBus/MultipleHandlersFoundTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/CommandBus/ServiceNotFoundTest.php b/tests/Unit/CommandBus/ServiceNotFoundTest.php new file mode 100644 index 000000000..e477c4b11 --- /dev/null +++ b/tests/Unit/CommandBus/ServiceNotFoundTest.php @@ -0,0 +1,21 @@ +getMessage()); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/CommandBus/SyncCommandBusTest.php b/tests/Unit/CommandBus/SyncCommandBusTest.php index 4b8713af1..cad8ce42f 100644 --- a/tests/Unit/CommandBus/SyncCommandBusTest.php +++ b/tests/Unit/CommandBus/SyncCommandBusTest.php @@ -9,6 +9,10 @@ use Patchlevel\EventSourcing\CommandBus\HandlerProvider; use Patchlevel\EventSourcing\CommandBus\MultipleHandlersFound; use Patchlevel\EventSourcing\CommandBus\SyncCommandBus; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; +use Patchlevel\EventSourcing\Repository\RepositoryManager; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\CreateProfile; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -73,4 +77,78 @@ public function __invoke(object $command): void self::assertSame($command, $handler->command); } + + public function testIterableHandlerProviders(): void + { + $command = new class { + }; + + $handler = new class { + public object|null $command = null; + + public function __invoke(object $command): void + { + $this->command = $command; + } + }; + + $handlerProvider = $this->createMock(HandlerProvider::class); + $handlerProvider + ->expects($this->once()) + ->method('handlerForCommand') + ->with($command::class) + ->willReturn([ + new HandlerDescriptor($handler), + ]); + + $commandBus = new SyncCommandBus([$handlerProvider]); + + $commandBus->dispatch($command); + + self::assertSame($command, $handler->command); + } + + public function testHandlerGenerator(): void + { + $command = new class { + }; + + $handler = new class { + public object|null $command = null; + + public function __invoke(object $command): void + { + $this->command = $command; + } + }; + + $handlerProvider = $this->createMock(HandlerProvider::class); + $handlerProvider + ->expects($this->once()) + ->method('handlerForCommand') + ->with($command::class) + ->willReturnCallback(static function () use ($handler) { + yield new HandlerDescriptor($handler); + }); + + $commandBus = new SyncCommandBus($handlerProvider); + + $commandBus->dispatch($command); + + self::assertSame($command, $handler->command); + } + + public function testCreateForAggregateHandlers(): void + { + $repositoryManager = $this->createMock(RepositoryManager::class); + + $commandBus = SyncCommandBus::createForAggregateHandlers( + new AggregateRootRegistry([]), + $repositoryManager, + ); + + $this->expectException(HandlerNotFound::class); + + $commandBus->dispatch(new CreateProfile(ProfileId::fromString('1'), 'foo')); + } } diff --git a/tests/Unit/Console/Command/ShowCommandTest.php b/tests/Unit/Console/Command/ShowCommandTest.php new file mode 100644 index 000000000..192ab07aa --- /dev/null +++ b/tests/Unit/Console/Command/ShowCommandTest.php @@ -0,0 +1,209 @@ +createMock(EventSerializer::class); + $eventSerializer->expects($this->never())->method('serialize'); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer->expects($this->never())->method('serialize'); + + $commandTester = new CommandTester(new ShowCommand($store, $eventSerializer, $headersSerializer)); + $commandTester->execute([]); + + $display = $commandTester->getDisplay(); + + self::assertSame(0, $commandTester->getStatusCode()); + self::assertStringContainsString('No more messages (0/0)', $display); + } + + public function testShowOneMessage(): void + { + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('foo@bar.com'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)); + + $store = new InMemoryStore([$message]); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->willReturn(new SerializedEvent('profile.created', '{"id":"1"}')); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->willReturn('[]'); + + $commandTester = new CommandTester(new ShowCommand($store, $eventSerializer, $headersSerializer)); + $commandTester->execute([]); + + $display = $commandTester->getDisplay(); + + self::assertSame(0, $commandTester->getStatusCode()); + self::assertStringContainsString('profile.created', $display); + self::assertStringContainsString('profile-1', $display); + self::assertStringContainsString('No more messages (1/1)', $display); + } + + public function testShowWithStream(): void + { + $message1 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('foo@bar.com'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)); + $message2 = Message::create(new ProfileCreated(ProfileId::fromString('2'), Email::fromString('foo@bar.com'))) + ->withHeader(new StreamNameHeader('other-1')) + ->withHeader(new PlayheadHeader(1)); + + $store = new InMemoryStore([$message1, $message2]); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->willReturn(new SerializedEvent('profile.created', '{"id":"1"}')); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->willReturn('[]'); + + $commandTester = new CommandTester(new ShowCommand($store, $eventSerializer, $headersSerializer)); + $commandTester->execute(['--stream' => 'profile-1']); + + $display = $commandTester->getDisplay(); + + self::assertSame(0, $commandTester->getStatusCode()); + self::assertStringContainsString('profile-1', $display); + self::assertStringNotContainsString('other-1', $display); + self::assertStringContainsString('No more messages (1/1)', $display); + } + + public function testShowWithLimitZero(): void + { + $message1 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('foo@bar.com'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)); + $message2 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('foo@bar.com'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)); + + $store = new InMemoryStore([$message1, $message2]); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->exactly(2)) + ->method('serialize') + ->willReturn(new SerializedEvent('profile.created', '{"id":"1"}')); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->exactly(2)) + ->method('serialize') + ->willReturn('[]'); + + $commandTester = new CommandTester(new ShowCommand($store, $eventSerializer, $headersSerializer)); + $commandTester->execute(['--limit' => 0]); + + $display = $commandTester->getDisplay(); + + self::assertSame(0, $commandTester->getStatusCode()); + self::assertStringContainsString('profile.created', $display); + self::assertStringNotContainsString('No more messages', $display); + } + + public function testShowNextBatchConfirmed(): void + { + $message1 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('foo@bar.com'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)); + $message2 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('foo@bar.com'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)); + + $store = new InMemoryStore([$message1, $message2]); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->exactly(2)) + ->method('serialize') + ->willReturn(new SerializedEvent('profile.created', '{"id":"1"}')); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->exactly(2)) + ->method('serialize') + ->willReturn('[]'); + + $commandTester = new CommandTester(new ShowCommand($store, $eventSerializer, $headersSerializer)); + $commandTester->setInputs(['yes']); + $commandTester->execute(['--limit' => 1]); + + $display = $commandTester->getDisplay(); + + self::assertSame(0, $commandTester->getStatusCode()); + self::assertStringContainsString('Show next 1 messages? (1/2)', $display); + self::assertStringContainsString('No more messages (2/2)', $display); + } + + public function testShowNextBatchAborted(): void + { + $message1 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('foo@bar.com'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)); + $message2 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('foo@bar.com'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)); + + $store = new InMemoryStore([$message1, $message2]); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->willReturn(new SerializedEvent('profile.created', '{"id":"1"}')); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->willReturn('[]'); + + $commandTester = new CommandTester(new ShowCommand($store, $eventSerializer, $headersSerializer)); + $commandTester->setInputs(['no']); + $commandTester->execute(['--limit' => 1]); + + $display = $commandTester->getDisplay(); + + self::assertSame(0, $commandTester->getStatusCode()); + self::assertStringContainsString('Show next 1 messages? (1/2)', $display); + self::assertStringNotContainsString('No more messages', $display); + } +} diff --git a/tests/Unit/Console/Command/SubscriptionBootCommandTest.php b/tests/Unit/Console/Command/SubscriptionBootCommandTest.php new file mode 100644 index 000000000..3715add79 --- /dev/null +++ b/tests/Unit/Console/Command/SubscriptionBootCommandTest.php @@ -0,0 +1,107 @@ +createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->with(new SubscriptionEngineCriteria(null, null)) + ->willReturn([new Subscription('foo')]); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Boot(['foo'], null, 1000)) + ->willReturn(new ProcessedResult(0, true)); + + $commandTester = new CommandTester(new SubscriptionBootCommand($engine)); + $commandTester->execute([]); + + self::assertSame(0, $commandTester->getStatusCode()); + } + + public function testBootWithSetup(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->with(new SubscriptionEngineCriteria(null, null)) + ->willReturn([new Subscription('foo')]); + $engine + ->expects($this->exactly(2)) + ->method('execute') + ->willReturnCallback(new ReturnCallback([ + [[new Setup(['foo'], null)], new Result()], + [[new Boot(['foo'], null, 1000)], new ProcessedResult(0, true)], + ])); + + $commandTester = new CommandTester(new SubscriptionBootCommand($engine)); + $commandTester->execute(['--setup' => true]); + + self::assertSame(0, $commandTester->getStatusCode()); + } + + public function testBootNotFinished(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->with(new SubscriptionEngineCriteria(null, null)) + ->willReturn([new Subscription('foo')]); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Boot(['foo'], null, 1000)) + ->willReturn(new ProcessedResult(0, false)); + + $commandTester = new CommandTester(new SubscriptionBootCommand($engine)); + $commandTester->execute(['--run-limit' => 1]); + + self::assertSame(1, $commandTester->getStatusCode()); + } + + public function testBootUnexpectedResult(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->with(new SubscriptionEngineCriteria(null, null)) + ->willReturn([new Subscription('foo')]); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Boot(['foo'], null, 1000)) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionBootCommand($engine)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Expected ProcessedResult'); + + $commandTester->execute([]); + } +} diff --git a/tests/Unit/Console/Command/SubscriptionPauseCommandTest.php b/tests/Unit/Console/Command/SubscriptionPauseCommandTest.php new file mode 100644 index 000000000..eb73e3bb3 --- /dev/null +++ b/tests/Unit/Console/Command/SubscriptionPauseCommandTest.php @@ -0,0 +1,47 @@ +createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Pause(null, null)) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionPauseCommand($engine)); + $commandTester->execute([]); + + self::assertSame(0, $commandTester->getStatusCode()); + } + + public function testPauseWithFilter(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Pause(['foo'], ['bar'])) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionPauseCommand($engine)); + $commandTester->execute(['--id' => ['foo'], '--group' => ['bar']]); + + self::assertSame(0, $commandTester->getStatusCode()); + } +} diff --git a/tests/Unit/Console/Command/SubscriptionReactivateCommandTest.php b/tests/Unit/Console/Command/SubscriptionReactivateCommandTest.php new file mode 100644 index 000000000..c021e5a42 --- /dev/null +++ b/tests/Unit/Console/Command/SubscriptionReactivateCommandTest.php @@ -0,0 +1,47 @@ +createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Reactivate(null, null)) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionReactivateCommand($engine)); + $commandTester->execute([]); + + self::assertSame(0, $commandTester->getStatusCode()); + } + + public function testReactivateWithFilter(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Reactivate(['foo'], ['bar'])) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionReactivateCommand($engine)); + $commandTester->execute(['--id' => ['foo'], '--group' => ['bar']]); + + self::assertSame(0, $commandTester->getStatusCode()); + } +} diff --git a/tests/Unit/Console/Command/SubscriptionRefreshCommandTest.php b/tests/Unit/Console/Command/SubscriptionRefreshCommandTest.php new file mode 100644 index 000000000..ea6d222bc --- /dev/null +++ b/tests/Unit/Console/Command/SubscriptionRefreshCommandTest.php @@ -0,0 +1,47 @@ +createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Refresh(null, null)) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionRefreshCommand($engine)); + $commandTester->execute([]); + + self::assertSame(0, $commandTester->getStatusCode()); + } + + public function testRefreshWithFilter(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Refresh(['foo'], ['bar'])) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionRefreshCommand($engine)); + $commandTester->execute(['--id' => ['foo'], '--group' => ['bar']]); + + self::assertSame(0, $commandTester->getStatusCode()); + } +} diff --git a/tests/Unit/Console/Command/SubscriptionRemoveCommandTest.php b/tests/Unit/Console/Command/SubscriptionRemoveCommandTest.php new file mode 100644 index 000000000..99dce4604 --- /dev/null +++ b/tests/Unit/Console/Command/SubscriptionRemoveCommandTest.php @@ -0,0 +1,77 @@ +createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Remove(null, null)) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionRemoveCommand($engine)); + $commandTester->execute(['--force' => true]); + + self::assertSame(0, $commandTester->getStatusCode()); + } + + public function testRemoveWithIds(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Remove(['foo'], null)) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionRemoveCommand($engine)); + $commandTester->execute(['--id' => ['foo']]); + + self::assertSame(0, $commandTester->getStatusCode()); + } + + public function testRemoveConfirmed(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Remove(null, null)) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionRemoveCommand($engine)); + $commandTester->setInputs(['yes']); + $commandTester->execute([]); + + self::assertSame(0, $commandTester->getStatusCode()); + } + + public function testRemoveAborted(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->never()) + ->method('execute'); + + $commandTester = new CommandTester(new SubscriptionRemoveCommand($engine)); + $commandTester->setInputs(['no']); + $commandTester->execute([]); + + self::assertSame(1, $commandTester->getStatusCode()); + } +} diff --git a/tests/Unit/Console/Command/SubscriptionRunCommandTest.php b/tests/Unit/Console/Command/SubscriptionRunCommandTest.php new file mode 100644 index 000000000..188f1603e --- /dev/null +++ b/tests/Unit/Console/Command/SubscriptionRunCommandTest.php @@ -0,0 +1,104 @@ +createMock(Store::class); + + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->with(new SubscriptionEngineCriteria(null, null)) + ->willReturn([new Subscription('foo')]); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Run(['foo'], null, 100)) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionRunCommand($engine, $store)); + $commandTester->execute(['--run-limit' => 1, '--sleep' => 0]); + + self::assertSame(0, $commandTester->getStatusCode()); + } + + public function testRunWithRebuild(): void + { + $store = $this->createMock(Store::class); + + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->with(new SubscriptionEngineCriteria(null, null)) + ->willReturn([new Subscription('foo')]); + $engine + ->expects($this->exactly(3)) + ->method('execute') + ->willReturnCallback(new ReturnCallback([ + [[new Remove(['foo'], null)], new Result()], + [[new Boot(['foo'], null)], new Result()], + [[new Run(['foo'], null, 100)], new Result()], + ])); + + $commandTester = new CommandTester(new SubscriptionRunCommand($engine, $store)); + $commandTester->execute(['--run-limit' => 1, '--sleep' => 0, '--rebuild' => true]); + + self::assertSame(0, $commandTester->getStatusCode()); + } + + public function testRunWithSubscriptionStore(): void + { + $store = $this->createMockForIntersectionOfInterfaces([Store::class, SubscriptionStore::class]); + $store + ->expects($this->once()) + ->method('setupSubscription'); + $store + ->expects($this->once()) + ->method('supportSubscription') + ->willReturn(true); + $store + ->expects($this->once()) + ->method('wait') + ->with(0); + + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->with(new SubscriptionEngineCriteria(null, null)) + ->willReturn([new Subscription('foo')]); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Run(['foo'], null, 100)) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionRunCommand($engine, $store)); + $commandTester->execute(['--run-limit' => 1, '--sleep' => 0]); + + self::assertSame(0, $commandTester->getStatusCode()); + } +} diff --git a/tests/Unit/Console/Command/SubscriptionSetupCommandTest.php b/tests/Unit/Console/Command/SubscriptionSetupCommandTest.php new file mode 100644 index 000000000..3f81c31d8 --- /dev/null +++ b/tests/Unit/Console/Command/SubscriptionSetupCommandTest.php @@ -0,0 +1,47 @@ +createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Setup(null, null, false)) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionSetupCommand($engine)); + $commandTester->execute([]); + + self::assertSame(0, $commandTester->getStatusCode()); + } + + public function testSetupWithSkipBooting(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Setup(['foo'], ['bar'], true)) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionSetupCommand($engine)); + $commandTester->execute(['--id' => ['foo'], '--group' => ['bar'], '--skip-booting' => true]); + + self::assertSame(0, $commandTester->getStatusCode()); + } +} diff --git a/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php b/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php new file mode 100644 index 000000000..48d043fc5 --- /dev/null +++ b/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php @@ -0,0 +1,111 @@ +createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->willReturn([ + new Subscription('foo'), + new Subscription('bar', 'other', RunMode::Once, Status::Active, 42), + ]); + + $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); + $commandTester->execute([]); + + $display = $commandTester->getDisplay(); + + self::assertSame(0, $commandTester->getStatusCode()); + self::assertStringContainsString('foo', $display); + self::assertStringContainsString('bar', $display); + self::assertStringContainsString('active', $display); + self::assertStringContainsString('42', $display); + } + + public function testStatusDetail(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->willReturn([ + new Subscription('foo', 'default', RunMode::FromBeginning, Status::Active, 42), + ]); + + $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); + $commandTester->execute(['id' => 'foo']); + + $display = $commandTester->getDisplay(); + + self::assertSame(0, $commandTester->getStatusCode()); + self::assertStringContainsString('foo', $display); + self::assertStringContainsString('active', $display); + self::assertStringContainsString('42', $display); + } + + public function testStatusDetailWithError(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->willReturn([ + new Subscription( + 'foo', + 'default', + RunMode::FromBeginning, + Status::Error, + 42, + SubscriptionError::fromThrowable( + Status::Active, + new RuntimeException('something went wrong'), + ), + ), + ]); + + $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); + $commandTester->execute(['id' => 'foo']); + + $display = $commandTester->getDisplay(); + + self::assertSame(0, $commandTester->getStatusCode()); + self::assertStringContainsString('foo', $display); + self::assertStringContainsString('error', $display); + self::assertStringContainsString('something went wrong', $display); + } + + public function testStatusNotFound(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->willReturn([new Subscription('foo')]); + + $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); + + $this->expectException(SubscriptionNotFound::class); + + $commandTester->execute(['id' => 'bar']); + } +} diff --git a/tests/Unit/Console/Command/SubscriptionTeardownCommandTest.php b/tests/Unit/Console/Command/SubscriptionTeardownCommandTest.php new file mode 100644 index 000000000..023d3892e --- /dev/null +++ b/tests/Unit/Console/Command/SubscriptionTeardownCommandTest.php @@ -0,0 +1,47 @@ +createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Teardown(null, null)) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionTeardownCommand($engine)); + $commandTester->execute([]); + + self::assertSame(0, $commandTester->getStatusCode()); + } + + public function testTeardownWithFilter(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('execute') + ->with(new Teardown(['foo'], ['bar'])) + ->willReturn(new Result()); + + $commandTester = new CommandTester(new SubscriptionTeardownCommand($engine)); + $commandTester->execute(['--id' => ['foo'], '--group' => ['bar']]); + + self::assertSame(0, $commandTester->getStatusCode()); + } +} diff --git a/tests/Unit/Console/InputHelperTest.php b/tests/Unit/Console/InputHelperTest.php index 2bd2787bf..98dd78fc8 100644 --- a/tests/Unit/Console/InputHelperTest.php +++ b/tests/Unit/Console/InputHelperTest.php @@ -81,4 +81,156 @@ public function testInvalidIntAsString(): void InputHelper::nullableInt('foo'); } + + public function testValidIntFromInt(): void + { + self::assertSame(1, InputHelper::int(1)); + } + + public function testValidIntFromString(): void + { + self::assertSame(2, InputHelper::int('2')); + } + + public function testInvalidIntFromBool(): void + { + $this->expectException(InvalidArgumentGiven::class); + $this->expectExceptionMessage('Invalid argument given: need type "int" got "bool"'); + + InputHelper::int(true); + } + + public function testInvalidIntFromNonNumericString(): void + { + $this->expectException(InvalidArgumentGiven::class); + $this->expectExceptionMessage('Invalid argument given: need type "int" got "string"'); + + InputHelper::int('foo'); + } + + public function testValidPositiveInt(): void + { + self::assertSame(1, InputHelper::positiveInt(1)); + self::assertSame(2, InputHelper::positiveInt('2')); + } + + public function testInvalidPositiveIntFromBool(): void + { + $this->expectException(InvalidArgumentGiven::class); + $this->expectExceptionMessage('Invalid argument given: need type "positive-int" got "bool"'); + + InputHelper::positiveInt(true); + } + + public function testInvalidPositiveIntFromNonNumericString(): void + { + $this->expectException(InvalidArgumentGiven::class); + $this->expectExceptionMessage('Invalid argument given: need type "positive-int" got "string"'); + + InputHelper::positiveInt('foo'); + } + + public function testInvalidPositiveIntFromZero(): void + { + $this->expectException(InvalidArgumentGiven::class); + $this->expectExceptionMessage('Invalid argument given: need type "positive-int" got "int"'); + + InputHelper::positiveInt(0); + } + + public function testValidNullablePositiveInt(): void + { + self::assertSame(1, InputHelper::nullablePositiveInt(1)); + self::assertSame(2, InputHelper::nullablePositiveInt('2')); + } + + public function testValidNullablePositiveIntIsNull(): void + { + self::assertSame(null, InputHelper::nullablePositiveInt(null)); + } + + public function testInvalidNullablePositiveIntFromBool(): void + { + $this->expectException(InvalidArgumentGiven::class); + $this->expectExceptionMessage('Invalid argument given: need type "positive-int|null" got "bool"'); + + InputHelper::nullablePositiveInt(true); + } + + public function testInvalidNullablePositiveIntFromNonNumericString(): void + { + $this->expectException(InvalidArgumentGiven::class); + $this->expectExceptionMessage('Invalid argument given: need type "positive-int|null" got "string"'); + + InputHelper::nullablePositiveInt('foo'); + } + + public function testInvalidNullablePositiveIntFromZero(): void + { + $this->expectException(InvalidArgumentGiven::class); + $this->expectExceptionMessage('Invalid argument given: need type "positive-int|null" got "int"'); + + InputHelper::nullablePositiveInt(0); + } + + public function testValidPositiveIntOrZero(): void + { + self::assertSame(0, InputHelper::positiveIntOrZero(0)); + self::assertSame(5, InputHelper::positiveIntOrZero('5')); + } + + public function testInvalidPositiveIntOrZeroFromBool(): void + { + $this->expectException(InvalidArgumentGiven::class); + $this->expectExceptionMessage('Invalid argument given: need type "positive-int|0" got "bool"'); + + InputHelper::positiveIntOrZero(true); + } + + public function testInvalidPositiveIntOrZeroFromNonNumericString(): void + { + $this->expectException(InvalidArgumentGiven::class); + $this->expectExceptionMessage('Invalid argument given: need type "positive-int|0" got "string"'); + + InputHelper::positiveIntOrZero('foo'); + } + + public function testInvalidPositiveIntOrZeroFromNegativeInt(): void + { + $this->expectException(InvalidArgumentGiven::class); + $this->expectExceptionMessage('Invalid argument given: need type "positive-int|0" got "int"'); + + InputHelper::positiveIntOrZero(-1); + } + + public function testValidNullableStringList(): void + { + self::assertSame(['foo', 'bar'], InputHelper::nullableStringList(['foo', 'bar'])); + } + + public function testValidNullableStringListIsNull(): void + { + self::assertSame(null, InputHelper::nullableStringList(null)); + } + + public function testValidNullableStringListFromEmptyArray(): void + { + self::assertSame(null, InputHelper::nullableStringList([])); + } + + public function testInvalidNullableStringList(): void + { + $this->expectException(InvalidArgumentGiven::class); + $this->expectExceptionMessage('Invalid argument given: need type "list|null" got "string"'); + + InputHelper::nullableStringList('foo'); + } + + public function testInvalidNullableStringListElement(): void + { + $this->expectException(InvalidArgumentGiven::class); + $this->expectExceptionMessage('Invalid argument given: need type "list|null" got "array"'); + + InputHelper::nullableStringList([1]); + } } diff --git a/tests/Unit/Console/InvalidArgumentGivenTest.php b/tests/Unit/Console/InvalidArgumentGivenTest.php index 5ad13c0de..470334b78 100644 --- a/tests/Unit/Console/InvalidArgumentGivenTest.php +++ b/tests/Unit/Console/InvalidArgumentGivenTest.php @@ -18,6 +18,7 @@ public function testException(): void self::assertSame('Invalid argument given: need type "int" got "string"', $exception->getMessage()); self::assertSame($expectedValue, $exception->value()); + self::assertSame('int', $exception->need()); self::assertSame(0, $exception->getCode()); } } diff --git a/tests/Unit/Console/OutputStyleTest.php b/tests/Unit/Console/OutputStyleTest.php index dde45c77b..210009ff1 100644 --- a/tests/Unit/Console/OutputStyleTest.php +++ b/tests/Unit/Console/OutputStyleTest.php @@ -20,8 +20,10 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; #[CoversClass(OutputStyle::class)] final class OutputStyleTest extends TestCase @@ -112,4 +114,100 @@ public function testMessageWithCustomHeaders(): void self::assertStringContainsString('stream', $content); self::assertStringContainsString('profile', $content); } + + public function testMessageWithSerializationError(): void + { + $input = new ArrayInput([]); + $output = new BufferedOutput(); + + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('foo@bar.com'), + ); + + $message = Message::create($event) + ->withHeader(new StreamNameHeader('profile-1')); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($event, [Encoder::OPTION_PRETTY_PRINT => true]) + ->willThrowException(new RuntimeException('boom')); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer->expects($this->never())->method('serialize'); + + $console = new OutputStyle($input, $output); + + $console->message( + $eventSerializer, + $headersSerializer, + $message, + ); + + $content = $output->fetch(); + + self::assertStringContainsString('Error while serializing event', $content); + self::assertStringContainsString('boom', $content); + self::assertStringNotContainsString('#0', $content); + } + + public function testMessageWithSerializationErrorVeryVerbose(): void + { + $input = new ArrayInput([]); + $output = new BufferedOutput(OutputInterface::VERBOSITY_VERY_VERBOSE); + + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('foo@bar.com'), + ); + + $message = Message::create($event) + ->withHeader(new StreamNameHeader('profile-1')); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($event, [Encoder::OPTION_PRETTY_PRINT => true]) + ->willThrowException(new RuntimeException('boom')); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer->expects($this->never())->method('serialize'); + + $console = new OutputStyle($input, $output); + + $console->message( + $eventSerializer, + $headersSerializer, + $message, + ); + + $content = $output->fetch(); + + self::assertStringContainsString('Error while serializing event', $content); + self::assertStringContainsString('1) boom', $content); + self::assertStringContainsString('#0', $content); + } + + public function testThrowable(): void + { + $input = new ArrayInput([]); + $output = new BufferedOutput(); + + $error = new RuntimeException( + 'outer error', + 0, + new RuntimeException('inner error'), + ); + + $console = new OutputStyle($input, $output); + $console->throwable($error); + + $content = $output->fetch(); + + self::assertStringContainsString('1) outer error', $content); + self::assertStringContainsString('2) inner error', $content); + } } diff --git a/tests/Unit/Cryptography/ExtensionDoctrineCipherKeyStoreTest.php b/tests/Unit/Cryptography/ExtensionDoctrineCipherKeyStoreTest.php new file mode 100644 index 000000000..55e0a2045 --- /dev/null +++ b/tests/Unit/Cryptography/ExtensionDoctrineCipherKeyStoreTest.php @@ -0,0 +1,246 @@ +createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('fetchAssociative') + ->with('SELECT * FROM cryptography_keys WHERE id = :id', ['id' => 'foo']) + ->willReturn([ + 'id' => 'foo', + 'subject_id' => 'profile-1', + 'crypto_key' => base64_encode('secret-key'), + 'crypto_method' => 'aes-256-gcm', + 'created_at' => '2024-01-01 10:00:00', + ]); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + + $store = new ExtensionDoctrineCipherKeyStore($connection); + + self::assertEquals( + new CipherKey( + 'foo', + 'profile-1', + 'secret-key', + 'aes-256-gcm', + new DateTimeImmutable('2024-01-01 10:00:00'), + ), + $store->get('foo'), + ); + } + + public function testGetNotFound(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('fetchAssociative') + ->with('SELECT * FROM cryptography_keys WHERE id = :id', ['id' => 'foo']) + ->willReturn(false); + + $store = new ExtensionDoctrineCipherKeyStore($connection); + + $this->expectException(CipherKeyNotExists::class); + + $store->get('foo'); + } + + public function testCurrentKeyFor(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('fetchAssociative') + ->with('SELECT * FROM cryptography_keys WHERE subject_id = :subject_id', ['subject_id' => 'profile-1']) + ->willReturn([ + 'id' => 'foo', + 'subject_id' => 'profile-1', + 'crypto_key' => base64_encode('secret-key'), + 'crypto_method' => 'aes-256-gcm', + 'created_at' => '2024-01-01 10:00:00', + ]); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + + $store = new ExtensionDoctrineCipherKeyStore($connection); + + self::assertEquals( + new CipherKey( + 'foo', + 'profile-1', + 'secret-key', + 'aes-256-gcm', + new DateTimeImmutable('2024-01-01 10:00:00'), + ), + $store->currentKeyFor('profile-1'), + ); + } + + public function testCurrentKeyForNotFound(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('fetchAssociative') + ->with('SELECT * FROM cryptography_keys WHERE subject_id = :subject_id', ['subject_id' => 'profile-1']) + ->willReturn(false); + + $store = new ExtensionDoctrineCipherKeyStore($connection); + + $this->expectException(CipherKeyNotExists::class); + + $store->currentKeyFor('profile-1'); + } + + public function testStore(): void + { + $platform = new SQLitePlatform(); + $createdAt = new DateTimeImmutable('2024-01-01 10:00:00'); + $expectedDate = Type::getType(Types::DATETIMETZ_IMMUTABLE)->convertToDatabaseValue($createdAt, $platform); + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($platform); + $connection + ->expects($this->once()) + ->method('insert') + ->with('cryptography_keys', [ + 'id' => 'foo', + 'subject_id' => 'profile-1', + 'crypto_key' => base64_encode('secret-key'), + 'crypto_method' => 'aes-256-gcm', + 'created_at' => $expectedDate, + ]); + + $store = new ExtensionDoctrineCipherKeyStore($connection); + + $store->store(new CipherKey( + 'foo', + 'profile-1', + 'secret-key', + 'aes-256-gcm', + $createdAt, + )); + } + + public function testRemove(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('delete') + ->with('cryptography_keys', ['id' => 'foo']); + + $store = new ExtensionDoctrineCipherKeyStore($connection); + + $store->remove('foo'); + } + + public function testRemoveWithSubjectId(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('delete') + ->with('cryptography_keys', ['subject_id' => 'profile-1']); + + $store = new ExtensionDoctrineCipherKeyStore($connection); + + $store->removeWithSubjectId('profile-1'); + } + + public function testCustomTableName(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('delete') + ->with('my_keys', ['id' => 'foo']); + + $store = new ExtensionDoctrineCipherKeyStore($connection, 'my_keys'); + + $store->remove('foo'); + } + + public function testConfigureSchema(): void + { + $connection = $this->createMock(Connection::class); + + $store = new ExtensionDoctrineCipherKeyStore($connection); + + $expectedSchema = new Schema(); + $table = $expectedSchema->createTable('cryptography_keys'); + $table->addColumn('id', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('subject_id', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('crypto_key', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('crypto_method', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('created_at', 'datetimetz_immutable') + ->setNotnull(true); + $table->setPrimaryKey(['id']); + $table->addIndex(['subject_id']); + + $schema = new Schema(); + $store->configureSchema($schema, $connection); + + self::assertEquals($expectedSchema, $schema); + } + + public function testConfigureSchemaWithDifferentDatabase(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db']); + + $differentConnection = $this->createMock(Connection::class); + $differentConnection + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db2']); + + $store = new ExtensionDoctrineCipherKeyStore($connection); + + $schema = new Schema(); + $store->configureSchema($schema, $differentConnection); + + self::assertEquals(new Schema(), $schema); + } +} diff --git a/tests/Unit/Fixture/BrokenAutoInitializableProfile.php b/tests/Unit/Fixture/BrokenAutoInitializableProfile.php new file mode 100644 index 000000000..84a72ab1a --- /dev/null +++ b/tests/Unit/Fixture/BrokenAutoInitializableProfile.php @@ -0,0 +1,24 @@ +recordThat(new ProfileVisited($this->id)); + } + + #[Apply] + protected function applyProfileCreated(ProfileCreated $event): void + { + $this->id = $event->profileId; + } + + #[Apply] + protected function applyProfileVisited(ProfileVisited $event): void + { + } + + public function playhead(): int + { + return parent::playhead() - 2; + } +} diff --git a/tests/Unit/Fixture/ProfileWithDuplicateApply.php b/tests/Unit/Fixture/ProfileWithDuplicateApply.php new file mode 100644 index 000000000..5b1a4044b --- /dev/null +++ b/tests/Unit/Fixture/ProfileWithDuplicateApply.php @@ -0,0 +1,27 @@ + ['bar' => 'baz']]; + + $missingHeaders = new MissingHeaders($headers); + + self::assertSame($headers, $missingHeaders->headers); + } +} diff --git a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php index 5ed313e60..dc8695ffe 100644 --- a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php +++ b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use Patchlevel\EventSourcing\Message\MissingHeaders; use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; +use Patchlevel\EventSourcing\Message\Serializer\InvalidArgument; use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory; use Patchlevel\EventSourcing\Metadata\Message\HeaderNameNotRegistered; use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder; @@ -152,4 +153,30 @@ public function testSerializeMissingHeadersRoundTrip(): void $content, ); } + + public function testDeserializeWithInvalidHeaderPayload(): void + { + $serializer = new DefaultHeadersSerializer( + (new AttributeMessageHeaderRegistryFactory())->create([ + __DIR__ . '/../../Fixture', + ]), + new MetadataHydrator(), + new JsonEncoder(), + ); + + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('header payload must be an array'); + + $serializer->deserialize('{"streamName":"profile-1"}'); + } + + public function testCreateDefault(): void + { + $serializer = DefaultHeadersSerializer::createDefault(); + + $content = $serializer->serialize([new StreamNameHeader('profile-1')]); + + self::assertEquals('{"streamName":{"streamName":"profile-1"}}', $content); + self::assertEquals([new StreamNameHeader('profile-1')], $serializer->deserialize($content)); + } } diff --git a/tests/Unit/Message/StreamClosedTest.php b/tests/Unit/Message/StreamClosedTest.php new file mode 100644 index 000000000..45c8fd9ee --- /dev/null +++ b/tests/Unit/Message/StreamClosedTest.php @@ -0,0 +1,21 @@ +getMessage()); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Message/StreamNotRewindableTest.php b/tests/Unit/Message/StreamNotRewindableTest.php new file mode 100644 index 000000000..00d1deeab --- /dev/null +++ b/tests/Unit/Message/StreamNotRewindableTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Message/StreamTest.php b/tests/Unit/Message/StreamTest.php index 22be44322..0ba373a63 100644 --- a/tests/Unit/Message/StreamTest.php +++ b/tests/Unit/Message/StreamTest.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Message; use DateTimeImmutable; +use IteratorAggregate; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Stream; use Patchlevel\EventSourcing\Message\StreamClosed; @@ -20,6 +21,7 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileVisited; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Traversable; #[CoversClass(Stream::class)] final class StreamTest extends TestCase @@ -152,6 +154,74 @@ public function testWithMiddlewares(): void self::assertSame(1, $resultMessages[2]->header(PlayheadHeader::class)->playhead); } + public function testTraversable(): void + { + $message = Message::create( + new ProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ), + ); + + $messages = new class ([$message]) implements IteratorAggregate { + /** @param list $messages */ + public function __construct( + private readonly array $messages, + ) { + } + + /** @return Traversable */ + public function getIterator(): Traversable + { + yield from $this->messages; + } + }; + + $stream = new Stream($messages); + + self::assertSame([$message], $stream->toList()); + } + + public function testToArray(): void + { + $message = Message::create( + new ProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ), + ); + + $stream = new Stream([5 => $message]); + + self::assertSame([5 => $message], $stream->toArray()); + } + + public function testChunk(): void + { + $messages = $this->messages(); + + $stream = new Stream($messages); + + $chunks = [...$stream->chunk(2)]; + + self::assertCount(3, $chunks); + self::assertCount(2, $chunks[0]->toList()); + self::assertCount(2, $chunks[1]->toList()); + self::assertCount(1, $chunks[2]->toList()); + } + + public function testChunkWithExactMultiple(): void + { + $messages = $this->messages(); + + $stream = new Stream([$messages[0], $messages[1]]); + + $chunks = [...$stream->chunk(2)]; + + self::assertCount(1, $chunks); + self::assertCount(2, $chunks[0]->toList()); + } + public function testTransformIsSinglePass(): void { $message = Message::create( diff --git a/tests/Unit/Message/Translator/ExtractEventTagTranslatorTest.php b/tests/Unit/Message/Translator/ExtractEventTagTranslatorTest.php new file mode 100644 index 000000000..7841fa2b1 --- /dev/null +++ b/tests/Unit/Message/Translator/ExtractEventTagTranslatorTest.php @@ -0,0 +1,91 @@ +createMock(EventTagExtractor::class); + $extractor + ->expects($this->once()) + ->method('extract') + ->with($event) + ->willReturn(['profile-1']); + + $translator = new ExtractEventTagTranslator($extractor); + + $result = $translator($message); + + self::assertCount(1, $result); + self::assertSame(['profile-1'], $result[0]->header(TagsHeader::class)->tags); + } + + public function testOverwriteAlreadyTagged(): void + { + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $message = Message::create($event) + ->withHeader(new TagsHeader(['old'])); + + $extractor = $this->createMock(EventTagExtractor::class); + $extractor + ->expects($this->once()) + ->method('extract') + ->with($event) + ->willReturn(['profile-1']); + + $translator = new ExtractEventTagTranslator($extractor); + + $result = $translator($message); + + self::assertCount(1, $result); + self::assertSame(['profile-1'], $result[0]->header(TagsHeader::class)->tags); + } + + public function testSkipAlreadyTagged(): void + { + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $message = Message::create($event) + ->withHeader(new TagsHeader(['old'])); + + $extractor = $this->createMock(EventTagExtractor::class); + $extractor + ->expects($this->never()) + ->method('extract'); + + $translator = new ExtractEventTagTranslator($extractor, skipAlreadyTagged: true); + + $result = $translator($message); + + self::assertSame([$message], $result); + self::assertSame(['old'], $result[0]->header(TagsHeader::class)->tags); + } +} diff --git a/tests/Unit/Message/Translator/RecalculatePlayheadTranslatorTest.php b/tests/Unit/Message/Translator/RecalculatePlayheadTranslatorTest.php index c53833940..1c53d55d2 100644 --- a/tests/Unit/Message/Translator/RecalculatePlayheadTranslatorTest.php +++ b/tests/Unit/Message/Translator/RecalculatePlayheadTranslatorTest.php @@ -115,4 +115,20 @@ public function testReset(): void self::assertSame('profile', $result[0]->header(StreamNameHeader::class)->streamName); self::assertSame(1, $result[0]->header(PlayheadHeader::class)->playhead); } + + public function testPassthroughWithoutHeaders(): void + { + $translator = new RecalculatePlayheadTranslator(); + + $message = Message::create( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hallo@patchlevel.de'), + ), + ); + + $result = $translator($message); + + self::assertSame([$message], $result); + } } diff --git a/tests/Unit/Message/Translator/UntilEventTranslatorTest.php b/tests/Unit/Message/Translator/UntilEventTranslatorTest.php index 8b9657630..59df1f0eb 100644 --- a/tests/Unit/Message/Translator/UntilEventTranslatorTest.php +++ b/tests/Unit/Message/Translator/UntilEventTranslatorTest.php @@ -52,4 +52,20 @@ public function testNegative(): void self::assertSame([], $result); } + + public function testPassthroughWithoutRecordedOnHeader(): void + { + $translator = new UntilEventTranslator(new DateTimeImmutable('2020-01-01 00:00:00')); + + $message = Message::create( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + ); + + $result = $translator($message); + + self::assertSame([$message], $result); + } } diff --git a/tests/Unit/Metadata/Aggregate/AggregateRootAlreadyInRegistryTest.php b/tests/Unit/Metadata/Aggregate/AggregateRootAlreadyInRegistryTest.php new file mode 100644 index 000000000..2c2177b1c --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/AggregateRootAlreadyInRegistryTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/AggregateRootClassNotRegisteredTest.php b/tests/Unit/Metadata/Aggregate/AggregateRootClassNotRegisteredTest.php new file mode 100644 index 000000000..79340e818 --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/AggregateRootClassNotRegisteredTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/AggregateRootIdNotFoundTest.php b/tests/Unit/Metadata/Aggregate/AggregateRootIdNotFoundTest.php new file mode 100644 index 000000000..23399955e --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/AggregateRootIdNotFoundTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/AggregateRootMetadataAwareMetadataFactoryTest.php b/tests/Unit/Metadata/Aggregate/AggregateRootMetadataAwareMetadataFactoryTest.php new file mode 100644 index 000000000..b4847d5ea --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/AggregateRootMetadataAwareMetadataFactoryTest.php @@ -0,0 +1,69 @@ +metadata(Profile::class); + + self::assertSame(Profile::class, $metadata->className); + self::assertSame('profile', $metadata->name); + } + + public function testMetadataWithoutMetadataAware(): void + { + $aggregate = new class implements AggregateRoot { + public function aggregateRootId(): Identifier + { + throw new RuntimeException('not implemented'); + } + + /** @param iterable $events */ + public function catchUp(iterable $events): void + { + } + + /** @return list */ + public function releaseEvents(): array + { + return []; + } + + /** + * @param iterable $events + * @param 0|positive-int $startPlayhead + */ + public static function createFromEvents(iterable $events, int $startPlayhead = 0): static + { + throw new RuntimeException('not implemented'); + } + + public function playhead(): int + { + return 0; + } + }; + + $factory = new AggregateRootMetadataAwareMetadataFactory(); + + $this->expectException(AggregateWithoutMetadataAware::class); + + $factory->metadata($aggregate::class); + } +} diff --git a/tests/Unit/Metadata/Aggregate/AggregateRootMetadataTest.php b/tests/Unit/Metadata/Aggregate/AggregateRootMetadataTest.php new file mode 100644 index 000000000..819aa288e --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/AggregateRootMetadataTest.php @@ -0,0 +1,90 @@ + 'applyProfileCreated'], + [ProfileCreated::class => true], + false, + $snapshot, + ); + + self::assertSame(Profile::class, $metadata->className); + self::assertSame('profile', $metadata->name); + self::assertSame('id', $metadata->idProperty); + self::assertSame([ProfileCreated::class => 'applyProfileCreated'], $metadata->applyMethods); + self::assertSame([ProfileCreated::class => true], $metadata->suppressEvents); + self::assertFalse($metadata->suppressAll); + self::assertSame($snapshot, $metadata->snapshot); + self::assertSame('profile-{id}', $metadata->streamName); + self::assertNull($metadata->autoInitializeMethod); + } + + public function testStreamNameWithAggregateId(): void + { + $metadata = new AggregateRootMetadata( + Profile::class, + 'profile', + 'id', + [], + [], + false, + null, + ); + + self::assertSame('profile-1', $metadata->streamName('1')); + } + + public function testStreamNameWithoutAggregateId(): void + { + $metadata = new AggregateRootMetadata( + Profile::class, + 'profile', + 'id', + [], + [], + false, + null, + 'profile', + ); + + self::assertSame('profile', $metadata->streamName()); + } + + public function testStreamNameMissingAggregateId(): void + { + $metadata = new AggregateRootMetadata( + Profile::class, + 'profile', + 'id', + [], + [], + false, + null, + ); + + $this->expectException(MissingAggregateIdForStreamName::class); + + $metadata->streamName(); + } +} diff --git a/tests/Unit/Metadata/Aggregate/AggregateRootNameNotRegisteredTest.php b/tests/Unit/Metadata/Aggregate/AggregateRootNameNotRegisteredTest.php new file mode 100644 index 000000000..6db84c51e --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/AggregateRootNameNotRegisteredTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/AggregateWithoutMetadataAwareTest.php b/tests/Unit/Metadata/Aggregate/AggregateWithoutMetadataAwareTest.php new file mode 100644 index 000000000..d60857c6d --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/AggregateWithoutMetadataAwareTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/ArgumentTypeIsMissingTest.php b/tests/Unit/Metadata/Aggregate/ArgumentTypeIsMissingTest.php new file mode 100644 index 000000000..be28247dd --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/ArgumentTypeIsMissingTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/ArgumentTypeIsNotAClassTest.php b/tests/Unit/Metadata/Aggregate/ArgumentTypeIsNotAClassTest.php new file mode 100644 index 000000000..cddf6ba6e --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/ArgumentTypeIsNotAClassTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/AttributeAggregateMetadataFactoryTest.php b/tests/Unit/Metadata/Aggregate/AttributeAggregateMetadataFactoryTest.php index 509b701bb..4286810d7 100644 --- a/tests/Unit/Metadata/Aggregate/AttributeAggregateMetadataFactoryTest.php +++ b/tests/Unit/Metadata/Aggregate/AttributeAggregateMetadataFactoryTest.php @@ -4,21 +4,34 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Metadata\Aggregate; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootIdNotFound; use Patchlevel\EventSourcing\Metadata\AggregateRoot\ArgumentTypeIsMissing; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\ArgumentTypeIsNotAClass; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AttributeAggregateRootMetadataFactory; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\ClassIsNotAnAggregate; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\DuplicateApplyMethod; use Patchlevel\EventSourcing\Metadata\AggregateRoot\DuplicateEmptyApplyAttribute; use Patchlevel\EventSourcing\Metadata\AggregateRoot\MixedApplyAttributeUsage; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\Snapshot; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\AutoInitializableProfile; use Patchlevel\EventSourcing\Tests\Unit\Fixture\MessageDeleted; use Patchlevel\EventSourcing\Tests\Unit\Fixture\NameChanged; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileVisited; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithAggregateStream; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithBrokenApplyBothUsage; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithBrokenApplyIntersection; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithBrokenApplyMultipleApply; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithBrokenApplyNoType; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithBrokenApplyStringType; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithBrokenApplyUnionIntersection; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithDuplicateApply; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithEmptyApply; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithoutAggregateAttribute; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithoutId; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithSharedApplyContext; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithSnapshot; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithStream; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithSuppressAll; use Patchlevel\EventSourcing\Tests\Unit\Fixture\SplittingEvent; @@ -69,7 +82,7 @@ public function testApplyWithNoEventClass(): void self::assertSame([], $metadata->suppressEvents); } - public function streamName(): void + public function testStreamName(): void { $metadataFactory = new AttributeAggregateRootMetadataFactory(); $metadata = $metadataFactory->metadata(ProfileWithStream::class); @@ -94,6 +107,14 @@ public function testBrokenApplyWithIntersectionType(): void $metadataFactory->metadata(ProfileWithBrokenApplyIntersection::class); } + public function testBrokenApplyWithUnionIntersectionType(): void + { + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + $this->expectException(ArgumentTypeIsMissing::class); + + $metadataFactory->metadata(ProfileWithBrokenApplyUnionIntersection::class); + } + public function testBrokenApplyWithMultipleApply(): void { $metadataFactory = new AttributeAggregateRootMetadataFactory(); @@ -127,4 +148,82 @@ public function testSharedApplyContext(): void self::assertFalse($metadata->suppressAll); self::assertSame([ProfileCreated::class => true], $metadata->suppressEvents); } + + public function testMetadataCache(): void + { + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + + self::assertSame( + $metadataFactory->metadata(Profile::class), + $metadataFactory->metadata(Profile::class), + ); + } + + public function testNotAnAggregate(): void + { + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + + $this->expectException(ClassIsNotAnAggregate::class); + + $metadataFactory->metadata(ProfileWithoutAggregateAttribute::class); + } + + public function testAggregateRootIdNotFound(): void + { + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + + $this->expectException(AggregateRootIdNotFound::class); + + $metadataFactory->metadata(ProfileWithoutId::class); + } + + public function testSnapshot(): void + { + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + $metadata = $metadataFactory->metadata(ProfileWithSnapshot::class); + + self::assertEquals(new Snapshot('memory', 2, '1'), $metadata->snapshot); + } + + public function testStreamNameFromString(): void + { + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + $metadata = $metadataFactory->metadata(ProfileWithStream::class); + + self::assertSame('other-{id}', $metadata->streamName); + } + + public function testStreamNameFromAggregateClass(): void + { + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + $metadata = $metadataFactory->metadata(ProfileWithAggregateStream::class); + + self::assertSame('profile-{id}', $metadata->streamName); + } + + public function testAutoInitializeMethod(): void + { + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + $metadata = $metadataFactory->metadata(AutoInitializableProfile::class); + + self::assertSame('initialize', $metadata->autoInitializeMethod); + } + + public function testApplyWithNotAClassType(): void + { + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + + $this->expectException(ArgumentTypeIsNotAClass::class); + + $metadataFactory->metadata(ProfileWithBrokenApplyStringType::class); + } + + public function testDuplicateApplyMethod(): void + { + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + + $this->expectException(DuplicateApplyMethod::class); + + $metadataFactory->metadata(ProfileWithDuplicateApply::class); + } } diff --git a/tests/Unit/Metadata/Aggregate/AttributeAggregateRootRegistryFactoryTest.php b/tests/Unit/Metadata/Aggregate/AttributeAggregateRootRegistryFactoryTest.php index 619f1fe0d..2a59a6a48 100644 --- a/tests/Unit/Metadata/Aggregate/AttributeAggregateRootRegistryFactoryTest.php +++ b/tests/Unit/Metadata/Aggregate/AttributeAggregateRootRegistryFactoryTest.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootAlreadyInRegistry; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AttributeAggregateRootRegistryFactory; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\NoAggregateRoot; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Message; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile; use PHPUnit\Framework\Attributes\CoversClass; @@ -33,4 +34,12 @@ public function testCreateRegistryWithDuplicateEventName(): void $factory = new AttributeAggregateRootRegistryFactory(); $factory->create([__DIR__ . '/Fixture']); } + + public function testCreateRegistryWithNonAggregateClass(): void + { + $this->expectException(NoAggregateRoot::class); + + $factory = new AttributeAggregateRootRegistryFactory(); + $factory->create([__DIR__ . '/InvalidFixture']); + } } diff --git a/tests/Unit/Metadata/Aggregate/ClassIsNotAnAggregateTest.php b/tests/Unit/Metadata/Aggregate/ClassIsNotAnAggregateTest.php new file mode 100644 index 000000000..d02bd2b4a --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/ClassIsNotAnAggregateTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/DuplicateApplyMethodTest.php b/tests/Unit/Metadata/Aggregate/DuplicateApplyMethodTest.php new file mode 100644 index 000000000..6248f31b0 --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/DuplicateApplyMethodTest.php @@ -0,0 +1,28 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/DuplicateEmptyApplyAttributeTest.php b/tests/Unit/Metadata/Aggregate/DuplicateEmptyApplyAttributeTest.php new file mode 100644 index 000000000..65853429f --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/DuplicateEmptyApplyAttributeTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/InvalidFixture/NotAggregate.php b/tests/Unit/Metadata/Aggregate/InvalidFixture/NotAggregate.php new file mode 100644 index 000000000..1e3249d3b --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/InvalidFixture/NotAggregate.php @@ -0,0 +1,12 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/MissingDataSubjectIdTest.php b/tests/Unit/Metadata/Aggregate/MissingDataSubjectIdTest.php new file mode 100644 index 000000000..2525007b4 --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/MissingDataSubjectIdTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/MixedApplyAttributeUsageTest.php b/tests/Unit/Metadata/Aggregate/MixedApplyAttributeUsageTest.php new file mode 100644 index 000000000..e7635f2d9 --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/MixedApplyAttributeUsageTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/MultipleDataSubjectIdTest.php b/tests/Unit/Metadata/Aggregate/MultipleDataSubjectIdTest.php new file mode 100644 index 000000000..ddd0f9b75 --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/MultipleDataSubjectIdTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/NoAggregateRootTest.php b/tests/Unit/Metadata/Aggregate/NoAggregateRootTest.php new file mode 100644 index 000000000..0f2fd6293 --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/NoAggregateRootTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Aggregate/Psr16AggregateRootMetadataFactoryTest.php b/tests/Unit/Metadata/Aggregate/Psr16AggregateRootMetadataFactoryTest.php new file mode 100644 index 000000000..6faa98adf --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/Psr16AggregateRootMetadataFactoryTest.php @@ -0,0 +1,69 @@ +createMock(CacheInterface::class); + $cache + ->expects($this->once()) + ->method('get') + ->with(Profile::class) + ->willReturn($value); + $cache + ->expects($this->never()) + ->method('set'); + + $innerFactory = $this->createMock(AggregateRootMetadataFactory::class); + $innerFactory + ->expects($this->never()) + ->method('metadata'); + + $factory = new Psr16AggregateRootMetadataFactory($innerFactory, $cache); + + self::assertSame($value, $factory->metadata(Profile::class)); + } + + public function testCacheMiss(): void + { + $value = new AggregateRootMetadata(Profile::class, 'profile', 'id', [], [], false, null); + + $cache = $this->createMock(CacheInterface::class); + $cache + ->expects($this->once()) + ->method('get') + ->with(Profile::class) + ->willReturn(null); + $cache + ->expects($this->once()) + ->method('set') + ->with(Profile::class, $value) + ->willReturn(true); + + $innerFactory = $this->createMock(AggregateRootMetadataFactory::class); + $innerFactory + ->expects($this->once()) + ->method('metadata') + ->with(Profile::class) + ->willReturn($value); + + $factory = new Psr16AggregateRootMetadataFactory($innerFactory, $cache); + + self::assertSame($value, $factory->metadata(Profile::class)); + } +} diff --git a/tests/Unit/Metadata/Aggregate/Psr16AggregateRootRegistryFactoryTest.php b/tests/Unit/Metadata/Aggregate/Psr16AggregateRootRegistryFactoryTest.php new file mode 100644 index 000000000..348d53ea7 --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/Psr16AggregateRootRegistryFactoryTest.php @@ -0,0 +1,68 @@ +createMock(CacheInterface::class); + $cache + ->expects($this->once()) + ->method('get') + ->with('aggregate_root_registry') + ->willReturn($value); + $cache + ->expects($this->never()) + ->method('set'); + + $innerFactory = $this->createMock(AggregateRootRegistryFactory::class); + $innerFactory + ->expects($this->never()) + ->method('create'); + + $factory = new Psr16AggregateRootRegistryFactory($innerFactory, $cache); + + self::assertSame($value, $factory->create(['/foo'])); + } + + public function testCacheMiss(): void + { + $value = new AggregateRootRegistry([]); + + $cache = $this->createMock(CacheInterface::class); + $cache + ->expects($this->once()) + ->method('get') + ->with('aggregate_root_registry') + ->willReturn(null); + $cache + ->expects($this->once()) + ->method('set') + ->with('aggregate_root_registry', $value) + ->willReturn(true); + + $innerFactory = $this->createMock(AggregateRootRegistryFactory::class); + $innerFactory + ->expects($this->once()) + ->method('create') + ->with(['/foo']) + ->willReturn($value); + + $factory = new Psr16AggregateRootRegistryFactory($innerFactory, $cache); + + self::assertSame($value, $factory->create(['/foo'])); + } +} diff --git a/tests/Unit/Metadata/Aggregate/Psr6AggregateRootMetadataFactoryTest.php b/tests/Unit/Metadata/Aggregate/Psr6AggregateRootMetadataFactoryTest.php new file mode 100644 index 000000000..99f68145d --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/Psr6AggregateRootMetadataFactoryTest.php @@ -0,0 +1,91 @@ +createMock(CacheItemInterface::class); + $item + ->expects($this->once()) + ->method('isHit') + ->willReturn(true); + $item + ->expects($this->once()) + ->method('get') + ->willReturn($value); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache + ->expects($this->once()) + ->method('getItem') + ->with(Profile::class) + ->willReturn($item); + $cache + ->expects($this->never()) + ->method('save'); + + $innerFactory = $this->createMock(AggregateRootMetadataFactory::class); + $innerFactory + ->expects($this->never()) + ->method('metadata'); + + $factory = new Psr6AggregateRootMetadataFactory($innerFactory, $cache); + + self::assertSame($value, $factory->metadata(Profile::class)); + } + + public function testCacheMiss(): void + { + $value = new AggregateRootMetadata(Profile::class, 'profile', 'id', [], [], false, null); + + $item = $this->createMock(CacheItemInterface::class); + $item + ->expects($this->once()) + ->method('isHit') + ->willReturn(false); + $item + ->expects($this->once()) + ->method('set') + ->with($value) + ->willReturnSelf(); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache + ->expects($this->once()) + ->method('getItem') + ->with(Profile::class) + ->willReturn($item); + $cache + ->expects($this->once()) + ->method('save') + ->with($item) + ->willReturn(true); + + $innerFactory = $this->createMock(AggregateRootMetadataFactory::class); + $innerFactory + ->expects($this->once()) + ->method('metadata') + ->with(Profile::class) + ->willReturn($value); + + $factory = new Psr6AggregateRootMetadataFactory($innerFactory, $cache); + + self::assertSame($value, $factory->metadata(Profile::class)); + } +} diff --git a/tests/Unit/Metadata/Aggregate/Psr6AggregateRootRegistryFactoryTest.php b/tests/Unit/Metadata/Aggregate/Psr6AggregateRootRegistryFactoryTest.php new file mode 100644 index 000000000..61f8c93b2 --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/Psr6AggregateRootRegistryFactoryTest.php @@ -0,0 +1,90 @@ +createMock(CacheItemInterface::class); + $item + ->expects($this->once()) + ->method('isHit') + ->willReturn(true); + $item + ->expects($this->once()) + ->method('get') + ->willReturn($value); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache + ->expects($this->once()) + ->method('getItem') + ->with('aggregate_root_registry') + ->willReturn($item); + $cache + ->expects($this->never()) + ->method('save'); + + $innerFactory = $this->createMock(AggregateRootRegistryFactory::class); + $innerFactory + ->expects($this->never()) + ->method('create'); + + $factory = new Psr6AggregateRootRegistryFactory($innerFactory, $cache); + + self::assertSame($value, $factory->create(['/foo'])); + } + + public function testCacheMiss(): void + { + $value = new AggregateRootRegistry([]); + + $item = $this->createMock(CacheItemInterface::class); + $item + ->expects($this->once()) + ->method('isHit') + ->willReturn(false); + $item + ->expects($this->once()) + ->method('set') + ->with($value) + ->willReturnSelf(); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache + ->expects($this->once()) + ->method('getItem') + ->with('aggregate_root_registry') + ->willReturn($item); + $cache + ->expects($this->once()) + ->method('save') + ->with($item) + ->willReturn(true); + + $innerFactory = $this->createMock(AggregateRootRegistryFactory::class); + $innerFactory + ->expects($this->once()) + ->method('create') + ->with(['/foo']) + ->willReturn($value); + + $factory = new Psr6AggregateRootRegistryFactory($innerFactory, $cache); + + self::assertSame($value, $factory->create(['/foo'])); + } +} diff --git a/tests/Unit/Metadata/Aggregate/SnapshotTest.php b/tests/Unit/Metadata/Aggregate/SnapshotTest.php new file mode 100644 index 000000000..5e7e346bc --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/SnapshotTest.php @@ -0,0 +1,31 @@ +store); + self::assertSame(10, $snapshot->batch); + self::assertSame('1', $snapshot->version); + } + + public function testInstantiateWithDefaults(): void + { + $snapshot = new Snapshot('default'); + + self::assertSame('default', $snapshot->store); + self::assertNull($snapshot->batch); + self::assertNull($snapshot->version); + } +} diff --git a/tests/Unit/Metadata/Aggregate/SubjectIdAndPersonalDataConflictTest.php b/tests/Unit/Metadata/Aggregate/SubjectIdAndPersonalDataConflictTest.php new file mode 100644 index 000000000..f57fa5941 --- /dev/null +++ b/tests/Unit/Metadata/Aggregate/SubjectIdAndPersonalDataConflictTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/ClassFinderTest.php b/tests/Unit/Metadata/ClassFinderTest.php index 16f256f44..49001ca2c 100644 --- a/tests/Unit/Metadata/ClassFinderTest.php +++ b/tests/Unit/Metadata/ClassFinderTest.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Metadata; use Patchlevel\EventSourcing\Metadata\ClassFinder; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -19,11 +20,19 @@ public function testEmpty(): void self::assertCount(0, $classes); } + public function testNoPaths(): void + { + $finder = new ClassFinder(); + $classes = $finder->findClassNames([]); + + self::assertCount(0, $classes); + } + public function testLoadDirectory(): void { $finder = new ClassFinder(); $classes = $finder->findClassNames([__DIR__ . '/../Fixture']); - self::assertContains('Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile', $classes); + self::assertContains(Profile::class, $classes); } } diff --git a/tests/Unit/Metadata/Event/AliasFixture/EventWithAlias.php b/tests/Unit/Metadata/Event/AliasFixture/EventWithAlias.php new file mode 100644 index 000000000..89a022fe1 --- /dev/null +++ b/tests/Unit/Metadata/Event/AliasFixture/EventWithAlias.php @@ -0,0 +1,12 @@ +name); self::assertSame(true, $metadata->splitStream); } + + public function testMetadataCache(): void + { + $event = new #[Event('profile_created')] + class { + }; + + $metadataFactory = new AttributeEventMetadataFactory(); + + self::assertSame( + $metadataFactory->metadata($event::class), + $metadataFactory->metadata($event::class), + ); + } } diff --git a/tests/Unit/Metadata/Event/AttributeEventRegistryFactoryTest.php b/tests/Unit/Metadata/Event/AttributeEventRegistryFactoryTest.php index d4c68f210..f6c8bf966 100644 --- a/tests/Unit/Metadata/Event/AttributeEventRegistryFactoryTest.php +++ b/tests/Unit/Metadata/Event/AttributeEventRegistryFactoryTest.php @@ -8,6 +8,7 @@ use Patchlevel\EventSourcing\Metadata\Event\EventAlreadyInRegistry; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Message; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; +use Patchlevel\EventSourcing\Tests\Unit\Metadata\Event\AliasFixture\EventWithAlias; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -31,4 +32,23 @@ public function testCreateRegistryWithDuplicateEventName(): void $factory = new AttributeEventRegistryFactory(); $factory->create([__DIR__ . '/Fixture']); } + + public function testCreateRegistryWithAliases(): void + { + $factory = new AttributeEventRegistryFactory(); + $registry = $factory->create([__DIR__ . '/AliasFixture']); + + self::assertTrue($registry->hasEventName('event_with_alias')); + self::assertTrue($registry->hasEventName('event_alias')); + self::assertSame(EventWithAlias::class, $registry->eventClass('event_alias')); + } + + public function testCreateRegistryWithDuplicateAlias(): void + { + $this->expectException(EventAlreadyInRegistry::class); + $this->expectExceptionMessage('The event name "duplicate_alias" is already used in the registry. Maybe you defined 2 events with the same name.'); + + $factory = new AttributeEventRegistryFactory(); + $factory->create([__DIR__ . '/DuplicateAliasFixture']); + } } diff --git a/tests/Unit/Metadata/Event/ClassIsNotAnEventTest.php b/tests/Unit/Metadata/Event/ClassIsNotAnEventTest.php new file mode 100644 index 000000000..bb4c55622 --- /dev/null +++ b/tests/Unit/Metadata/Event/ClassIsNotAnEventTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Event/DuplicateAliasFixture/AliasedEvent.php b/tests/Unit/Metadata/Event/DuplicateAliasFixture/AliasedEvent.php new file mode 100644 index 000000000..db604d92d --- /dev/null +++ b/tests/Unit/Metadata/Event/DuplicateAliasFixture/AliasedEvent.php @@ -0,0 +1,12 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Event/EventClassNotRegisteredTest.php b/tests/Unit/Metadata/Event/EventClassNotRegisteredTest.php new file mode 100644 index 000000000..52d0997dc --- /dev/null +++ b/tests/Unit/Metadata/Event/EventClassNotRegisteredTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Event/EventMetadataTest.php b/tests/Unit/Metadata/Event/EventMetadataTest.php new file mode 100644 index 000000000..2d0c29eb5 --- /dev/null +++ b/tests/Unit/Metadata/Event/EventMetadataTest.php @@ -0,0 +1,31 @@ +name); + self::assertTrue($metadata->splitStream); + self::assertSame(['profile_created'], $metadata->aliases); + } + + public function testInstantiateWithDefaults(): void + { + $metadata = new EventMetadata('profile.created'); + + self::assertSame('profile.created', $metadata->name); + self::assertFalse($metadata->splitStream); + self::assertSame([], $metadata->aliases); + } +} diff --git a/tests/Unit/Metadata/Event/EventNameNotRegisteredTest.php b/tests/Unit/Metadata/Event/EventNameNotRegisteredTest.php new file mode 100644 index 000000000..01f3de426 --- /dev/null +++ b/tests/Unit/Metadata/Event/EventNameNotRegisteredTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Event/MissingDataSubjectIdTest.php b/tests/Unit/Metadata/Event/MissingDataSubjectIdTest.php new file mode 100644 index 000000000..5f59e2be9 --- /dev/null +++ b/tests/Unit/Metadata/Event/MissingDataSubjectIdTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Event/MultipleDataSubjectIdTest.php b/tests/Unit/Metadata/Event/MultipleDataSubjectIdTest.php new file mode 100644 index 000000000..3d8088c67 --- /dev/null +++ b/tests/Unit/Metadata/Event/MultipleDataSubjectIdTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Event/Psr16EventMetadataFactoryTest.php b/tests/Unit/Metadata/Event/Psr16EventMetadataFactoryTest.php new file mode 100644 index 000000000..cf869ba9c --- /dev/null +++ b/tests/Unit/Metadata/Event/Psr16EventMetadataFactoryTest.php @@ -0,0 +1,69 @@ +createMock(CacheInterface::class); + $cache + ->expects($this->once()) + ->method('get') + ->with(ProfileCreated::class) + ->willReturn($value); + $cache + ->expects($this->never()) + ->method('set'); + + $innerFactory = $this->createMock(EventMetadataFactory::class); + $innerFactory + ->expects($this->never()) + ->method('metadata'); + + $factory = new Psr16EventMetadataFactory($innerFactory, $cache); + + self::assertSame($value, $factory->metadata(ProfileCreated::class)); + } + + public function testCacheMiss(): void + { + $value = new EventMetadata('profile.created'); + + $cache = $this->createMock(CacheInterface::class); + $cache + ->expects($this->once()) + ->method('get') + ->with(ProfileCreated::class) + ->willReturn(null); + $cache + ->expects($this->once()) + ->method('set') + ->with(ProfileCreated::class, $value) + ->willReturn(true); + + $innerFactory = $this->createMock(EventMetadataFactory::class); + $innerFactory + ->expects($this->once()) + ->method('metadata') + ->with(ProfileCreated::class) + ->willReturn($value); + + $factory = new Psr16EventMetadataFactory($innerFactory, $cache); + + self::assertSame($value, $factory->metadata(ProfileCreated::class)); + } +} diff --git a/tests/Unit/Metadata/Event/Psr16EventRegistryFactoryTest.php b/tests/Unit/Metadata/Event/Psr16EventRegistryFactoryTest.php new file mode 100644 index 000000000..95ce347b6 --- /dev/null +++ b/tests/Unit/Metadata/Event/Psr16EventRegistryFactoryTest.php @@ -0,0 +1,68 @@ +createMock(CacheInterface::class); + $cache + ->expects($this->once()) + ->method('get') + ->with('event_registry') + ->willReturn($value); + $cache + ->expects($this->never()) + ->method('set'); + + $innerFactory = $this->createMock(EventRegistryFactory::class); + $innerFactory + ->expects($this->never()) + ->method('create'); + + $factory = new Psr16EventRegistryFactory($innerFactory, $cache); + + self::assertSame($value, $factory->create(['/foo'])); + } + + public function testCacheMiss(): void + { + $value = new EventRegistry([]); + + $cache = $this->createMock(CacheInterface::class); + $cache + ->expects($this->once()) + ->method('get') + ->with('event_registry') + ->willReturn(null); + $cache + ->expects($this->once()) + ->method('set') + ->with('event_registry', $value) + ->willReturn(true); + + $innerFactory = $this->createMock(EventRegistryFactory::class); + $innerFactory + ->expects($this->once()) + ->method('create') + ->with(['/foo']) + ->willReturn($value); + + $factory = new Psr16EventRegistryFactory($innerFactory, $cache); + + self::assertSame($value, $factory->create(['/foo'])); + } +} diff --git a/tests/Unit/Metadata/Event/Psr6EventMetadataFactoryTest.php b/tests/Unit/Metadata/Event/Psr6EventMetadataFactoryTest.php new file mode 100644 index 000000000..fb3176d8b --- /dev/null +++ b/tests/Unit/Metadata/Event/Psr6EventMetadataFactoryTest.php @@ -0,0 +1,91 @@ +createMock(CacheItemInterface::class); + $item + ->expects($this->once()) + ->method('isHit') + ->willReturn(true); + $item + ->expects($this->once()) + ->method('get') + ->willReturn($value); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache + ->expects($this->once()) + ->method('getItem') + ->with(ProfileCreated::class) + ->willReturn($item); + $cache + ->expects($this->never()) + ->method('save'); + + $innerFactory = $this->createMock(EventMetadataFactory::class); + $innerFactory + ->expects($this->never()) + ->method('metadata'); + + $factory = new Psr6EventMetadataFactory($innerFactory, $cache); + + self::assertSame($value, $factory->metadata(ProfileCreated::class)); + } + + public function testCacheMiss(): void + { + $value = new EventMetadata('profile.created'); + + $item = $this->createMock(CacheItemInterface::class); + $item + ->expects($this->once()) + ->method('isHit') + ->willReturn(false); + $item + ->expects($this->once()) + ->method('set') + ->with($value) + ->willReturnSelf(); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache + ->expects($this->once()) + ->method('getItem') + ->with(ProfileCreated::class) + ->willReturn($item); + $cache + ->expects($this->once()) + ->method('save') + ->with($item) + ->willReturn(true); + + $innerFactory = $this->createMock(EventMetadataFactory::class); + $innerFactory + ->expects($this->once()) + ->method('metadata') + ->with(ProfileCreated::class) + ->willReturn($value); + + $factory = new Psr6EventMetadataFactory($innerFactory, $cache); + + self::assertSame($value, $factory->metadata(ProfileCreated::class)); + } +} diff --git a/tests/Unit/Metadata/Event/Psr6EventRegistryFactoryTest.php b/tests/Unit/Metadata/Event/Psr6EventRegistryFactoryTest.php new file mode 100644 index 000000000..b1371334f --- /dev/null +++ b/tests/Unit/Metadata/Event/Psr6EventRegistryFactoryTest.php @@ -0,0 +1,90 @@ +createMock(CacheItemInterface::class); + $item + ->expects($this->once()) + ->method('isHit') + ->willReturn(true); + $item + ->expects($this->once()) + ->method('get') + ->willReturn($value); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache + ->expects($this->once()) + ->method('getItem') + ->with('event_registry') + ->willReturn($item); + $cache + ->expects($this->never()) + ->method('save'); + + $innerFactory = $this->createMock(EventRegistryFactory::class); + $innerFactory + ->expects($this->never()) + ->method('create'); + + $factory = new Psr6EventRegistryFactory($innerFactory, $cache); + + self::assertSame($value, $factory->create(['/foo'])); + } + + public function testCacheMiss(): void + { + $value = new EventRegistry([]); + + $item = $this->createMock(CacheItemInterface::class); + $item + ->expects($this->once()) + ->method('isHit') + ->willReturn(false); + $item + ->expects($this->once()) + ->method('set') + ->with($value) + ->willReturnSelf(); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache + ->expects($this->once()) + ->method('getItem') + ->with('event_registry') + ->willReturn($item); + $cache + ->expects($this->once()) + ->method('save') + ->with($item) + ->willReturn(true); + + $innerFactory = $this->createMock(EventRegistryFactory::class); + $innerFactory + ->expects($this->once()) + ->method('create') + ->with(['/foo']) + ->willReturn($value); + + $factory = new Psr6EventRegistryFactory($innerFactory, $cache); + + self::assertSame($value, $factory->create(['/foo'])); + } +} diff --git a/tests/Unit/Metadata/Event/SubjectIdAndPersonalDataConflictTest.php b/tests/Unit/Metadata/Event/SubjectIdAndPersonalDataConflictTest.php new file mode 100644 index 000000000..36b72594b --- /dev/null +++ b/tests/Unit/Metadata/Event/SubjectIdAndPersonalDataConflictTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php b/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php new file mode 100644 index 000000000..e90ee0543 --- /dev/null +++ b/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php @@ -0,0 +1,27 @@ +create([ + __DIR__ . '/../../Fixture', + ]); + + self::assertSame(FooHeader::class, $registry->headerClass('foo')); + self::assertSame(BazHeader::class, $registry->headerClass('baz')); + self::assertSame(StreamNameHeader::class, $registry->headerClass('streamName')); + } +} diff --git a/tests/Unit/Metadata/Message/HeaderClassNotRegisteredTest.php b/tests/Unit/Metadata/Message/HeaderClassNotRegisteredTest.php new file mode 100644 index 000000000..b23bccfed --- /dev/null +++ b/tests/Unit/Metadata/Message/HeaderClassNotRegisteredTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Message/HeaderNameNotRegisteredTest.php b/tests/Unit/Metadata/Message/HeaderNameNotRegisteredTest.php new file mode 100644 index 000000000..7a1112fd0 --- /dev/null +++ b/tests/Unit/Metadata/Message/HeaderNameNotRegisteredTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Message/MessageHeaderRegistryTest.php b/tests/Unit/Metadata/Message/MessageHeaderRegistryTest.php new file mode 100644 index 000000000..87e913cc8 --- /dev/null +++ b/tests/Unit/Metadata/Message/MessageHeaderRegistryTest.php @@ -0,0 +1,89 @@ + FooHeader::class]); + + self::assertSame('foo', $registry->headerName(FooHeader::class)); + } + + public function testHeaderNameNotRegistered(): void + { + $registry = new MessageHeaderRegistry(['foo' => FooHeader::class]); + + $this->expectException(HeaderClassNotRegistered::class); + + $registry->headerName(BazHeader::class); + } + + public function testHeaderClass(): void + { + $registry = new MessageHeaderRegistry(['foo' => FooHeader::class]); + + self::assertSame(FooHeader::class, $registry->headerClass('foo')); + } + + public function testHeaderClassNotRegistered(): void + { + $registry = new MessageHeaderRegistry(['foo' => FooHeader::class]); + + $this->expectException(HeaderNameNotRegistered::class); + + $registry->headerClass('baz'); + } + + public function testHasHeaderClass(): void + { + $registry = new MessageHeaderRegistry(['foo' => FooHeader::class]); + + self::assertTrue($registry->hasHeaderClass(FooHeader::class)); + self::assertFalse($registry->hasHeaderClass(BazHeader::class)); + } + + public function testHasHeaderName(): void + { + $registry = new MessageHeaderRegistry(['foo' => FooHeader::class]); + + self::assertTrue($registry->hasHeaderName('foo')); + self::assertFalse($registry->hasHeaderName('baz')); + } + + public function testHeaderClassesAndNames(): void + { + $registry = new MessageHeaderRegistry(['foo' => FooHeader::class]); + + self::assertSame(['foo' => FooHeader::class], $registry->headerClasses()); + self::assertSame([FooHeader::class => 'foo'], $registry->headerNames()); + } + + public function testCreateWithInternalHeaders(): void + { + $registry = MessageHeaderRegistry::createWithInternalHeaders(['foo' => FooHeader::class]); + + self::assertSame(FooHeader::class, $registry->headerClass('foo')); + self::assertSame(StreamNameHeader::class, $registry->headerClass('streamName')); + self::assertTrue($registry->hasHeaderName('playhead')); + self::assertTrue($registry->hasHeaderName('recordedOn')); + self::assertTrue($registry->hasHeaderName('archived')); + self::assertTrue($registry->hasHeaderName('newStreamStart')); + self::assertTrue($registry->hasHeaderName('eventId')); + self::assertTrue($registry->hasHeaderName('index')); + self::assertTrue($registry->hasHeaderName('tags')); + } +} diff --git a/tests/Unit/Metadata/Subscriber/ArgumentTypeNotSupportedTest.php b/tests/Unit/Metadata/Subscriber/ArgumentTypeNotSupportedTest.php new file mode 100644 index 000000000..64709fb1a --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/ArgumentTypeNotSupportedTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php index a31c05d62..3cbf71c69 100644 --- a/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php +++ b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php @@ -9,10 +9,13 @@ use Patchlevel\EventSourcing\Attribute\BatchRollback; use Patchlevel\EventSourcing\Attribute\BatchShouldFlush; use Patchlevel\EventSourcing\Attribute\BatchState; +use Patchlevel\EventSourcing\Attribute\Cleanup; use Patchlevel\EventSourcing\Attribute\DisableEventEmitting; use Patchlevel\EventSourcing\Attribute\EnableEventEmittingDuringBoot; +use Patchlevel\EventSourcing\Attribute\OnFailed; use Patchlevel\EventSourcing\Attribute\Processor; use Patchlevel\EventSourcing\Attribute\Projector; +use Patchlevel\EventSourcing\Attribute\RetryStrategy; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; @@ -24,6 +27,8 @@ use Patchlevel\EventSourcing\Metadata\Subscriber\BatchMetadata; use Patchlevel\EventSourcing\Metadata\Subscriber\ClassIsNotASubscriber; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateBeginBatchMethod; +use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateCleanupMethod; +use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateFailedMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateFlushMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateRollbackBatchMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateSetupMethod; @@ -31,6 +36,7 @@ use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateSubscribeMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateTeardownMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\IncompleteBatchMethods; +use Patchlevel\EventSourcing\Metadata\Subscriber\MixedTeardownAndCleanupMethods; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscribeMethodMetadata; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; @@ -649,4 +655,150 @@ public function rollback2(object $state): void $metadataFactory = new AttributeSubscriberMetadataFactory(); $metadataFactory->metadata($subscriber::class); } + + public function testMetadataCache(): void + { + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + + self::assertSame( + $metadataFactory->metadata($subscriber::class), + $metadataFactory->metadata($subscriber::class), + ); + } + + public function testOnFailedMethod(): void + { + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + #[OnFailed] + public function onFailed(): void + { + } + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadata = $metadataFactory->metadata($subscriber::class); + + self::assertSame('onFailed', $metadata->failedMethod); + } + + public function testDuplicateFailedException(): void + { + $this->expectException(DuplicateFailedMethod::class); + $this->expectExceptionMessage('have been marked as "OnFailed" methods'); + + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + #[OnFailed] + public function onFailed1(): void + { + } + + #[OnFailed] + public function onFailed2(): void + { + } + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadataFactory->metadata($subscriber::class); + } + + public function testCleanupMethod(): void + { + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + #[Cleanup] + public function cleanup(): void + { + } + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadata = $metadataFactory->metadata($subscriber::class); + + self::assertSame('cleanup', $metadata->cleanupMethod); + self::assertNull($metadata->teardownMethod); + } + + public function testDuplicateCleanupException(): void + { + $this->expectException(DuplicateCleanupMethod::class); + $this->expectExceptionMessage('have been marked as "cleanup" methods'); + + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + #[Cleanup] + public function cleanup1(): void + { + } + + #[Cleanup] + public function cleanup2(): void + { + } + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadataFactory->metadata($subscriber::class); + } + + public function testCleanupAfterTeardownException(): void + { + $this->expectException(MixedTeardownAndCleanupMethods::class); + + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + #[Teardown] + public function teardown(): void + { + } + + #[Cleanup] + public function cleanup(): void + { + } + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadataFactory->metadata($subscriber::class); + } + + public function testTeardownAfterCleanupException(): void + { + $this->expectException(MixedTeardownAndCleanupMethods::class); + + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + #[Cleanup] + public function cleanup(): void + { + } + + #[Teardown] + public function teardown(): void + { + } + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadataFactory->metadata($subscriber::class); + } + + public function testRetryStrategy(): void + { + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + #[RetryStrategy('custom_strategy')] + class { + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadata = $metadataFactory->metadata($subscriber::class); + + self::assertSame('custom_strategy', $metadata->retryStrategy); + } } diff --git a/tests/Unit/Metadata/Subscriber/BatchMetadataTest.php b/tests/Unit/Metadata/Subscriber/BatchMetadataTest.php new file mode 100644 index 000000000..e0a70fb7b --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/BatchMetadataTest.php @@ -0,0 +1,35 @@ +flushMethod); + self::assertSame('begin', $metadata->beginMethod); + self::assertSame('shouldFlush', $metadata->shouldFlushMethod); + self::assertSame('rollback', $metadata->rollbackMethod); + self::assertSame(100, $metadata->afterMessages); + } + + public function testInstantiateWithDefaults(): void + { + $metadata = new BatchMetadata('flush'); + + self::assertSame('flush', $metadata->flushMethod); + self::assertNull($metadata->beginMethod); + self::assertNull($metadata->shouldFlushMethod); + self::assertNull($metadata->rollbackMethod); + self::assertNull($metadata->afterMessages); + } +} diff --git a/tests/Unit/Metadata/Subscriber/ClassIsNotASubscriberTest.php b/tests/Unit/Metadata/Subscriber/ClassIsNotASubscriberTest.php new file mode 100644 index 000000000..a86b70c9a --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/ClassIsNotASubscriberTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Subscriber/DuplicateBeginBatchMethodTest.php b/tests/Unit/Metadata/Subscriber/DuplicateBeginBatchMethodTest.php new file mode 100644 index 000000000..2bd79c669 --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/DuplicateBeginBatchMethodTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Subscriber/DuplicateCleanupMethodTest.php b/tests/Unit/Metadata/Subscriber/DuplicateCleanupMethodTest.php new file mode 100644 index 000000000..e9893b2d9 --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/DuplicateCleanupMethodTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Subscriber/DuplicateFailedMethodTest.php b/tests/Unit/Metadata/Subscriber/DuplicateFailedMethodTest.php new file mode 100644 index 000000000..ee8148660 --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/DuplicateFailedMethodTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Subscriber/DuplicateFlushMethodTest.php b/tests/Unit/Metadata/Subscriber/DuplicateFlushMethodTest.php new file mode 100644 index 000000000..61d1d0c3c --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/DuplicateFlushMethodTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Subscriber/DuplicateRollbackBatchMethodTest.php b/tests/Unit/Metadata/Subscriber/DuplicateRollbackBatchMethodTest.php new file mode 100644 index 000000000..45f976f24 --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/DuplicateRollbackBatchMethodTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Subscriber/DuplicateSetupMethodTest.php b/tests/Unit/Metadata/Subscriber/DuplicateSetupMethodTest.php new file mode 100644 index 000000000..6348f567b --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/DuplicateSetupMethodTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Subscriber/DuplicateShouldFlushMethodTest.php b/tests/Unit/Metadata/Subscriber/DuplicateShouldFlushMethodTest.php new file mode 100644 index 000000000..9417800aa --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/DuplicateShouldFlushMethodTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Subscriber/DuplicateSubscribeMethodTest.php b/tests/Unit/Metadata/Subscriber/DuplicateSubscribeMethodTest.php new file mode 100644 index 000000000..dd8aaa1b9 --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/DuplicateSubscribeMethodTest.php @@ -0,0 +1,39 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testMixedWithAll(): void + { + $exception = DuplicateSubscribeMethod::mixedWithAll(Profile::class); + + self::assertSame( + sprintf('The subscriber "%s" is subscribing explicit events and all events. A subscriber can only listen once to a event, thus this is not allowed.', Profile::class), + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Subscriber/DuplicateTeardownMethodTest.php b/tests/Unit/Metadata/Subscriber/DuplicateTeardownMethodTest.php new file mode 100644 index 000000000..c06b5d187 --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/DuplicateTeardownMethodTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Subscriber/IncompleteBatchMethodsTest.php b/tests/Unit/Metadata/Subscriber/IncompleteBatchMethodsTest.php new file mode 100644 index 000000000..3f62b4a5f --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/IncompleteBatchMethodsTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Subscriber/MixedTeardownAndCleanupMethodsTest.php b/tests/Unit/Metadata/Subscriber/MixedTeardownAndCleanupMethodsTest.php new file mode 100644 index 000000000..c96594552 --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/MixedTeardownAndCleanupMethodsTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Subscriber/Psr16SubscriberMetadataFactoryTest.php b/tests/Unit/Metadata/Subscriber/Psr16SubscriberMetadataFactoryTest.php new file mode 100644 index 000000000..a68d96bd2 --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/Psr16SubscriberMetadataFactoryTest.php @@ -0,0 +1,69 @@ +createMock(CacheInterface::class); + $cache + ->expects($this->once()) + ->method('get') + ->with(Profile::class) + ->willReturn($value); + $cache + ->expects($this->never()) + ->method('set'); + + $innerFactory = $this->createMock(SubscriberMetadataFactory::class); + $innerFactory + ->expects($this->never()) + ->method('metadata'); + + $factory = new Psr16SubscriberMetadataFactory($innerFactory, $cache); + + self::assertSame($value, $factory->metadata(Profile::class)); + } + + public function testCacheMiss(): void + { + $value = new SubscriberMetadata('foo'); + + $cache = $this->createMock(CacheInterface::class); + $cache + ->expects($this->once()) + ->method('get') + ->with(Profile::class) + ->willReturn(null); + $cache + ->expects($this->once()) + ->method('set') + ->with(Profile::class, $value) + ->willReturn(true); + + $innerFactory = $this->createMock(SubscriberMetadataFactory::class); + $innerFactory + ->expects($this->once()) + ->method('metadata') + ->with(Profile::class) + ->willReturn($value); + + $factory = new Psr16SubscriberMetadataFactory($innerFactory, $cache); + + self::assertSame($value, $factory->metadata(Profile::class)); + } +} diff --git a/tests/Unit/Metadata/Subscriber/Psr6SubscriberMetadataFactoryTest.php b/tests/Unit/Metadata/Subscriber/Psr6SubscriberMetadataFactoryTest.php new file mode 100644 index 000000000..d1074dade --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/Psr6SubscriberMetadataFactoryTest.php @@ -0,0 +1,91 @@ +createMock(CacheItemInterface::class); + $item + ->expects($this->once()) + ->method('isHit') + ->willReturn(true); + $item + ->expects($this->once()) + ->method('get') + ->willReturn($value); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache + ->expects($this->once()) + ->method('getItem') + ->with(Profile::class) + ->willReturn($item); + $cache + ->expects($this->never()) + ->method('save'); + + $innerFactory = $this->createMock(SubscriberMetadataFactory::class); + $innerFactory + ->expects($this->never()) + ->method('metadata'); + + $factory = new Psr6SubscriberMetadataFactory($innerFactory, $cache); + + self::assertSame($value, $factory->metadata(Profile::class)); + } + + public function testCacheMiss(): void + { + $value = new SubscriberMetadata('foo'); + + $item = $this->createMock(CacheItemInterface::class); + $item + ->expects($this->once()) + ->method('isHit') + ->willReturn(false); + $item + ->expects($this->once()) + ->method('set') + ->with($value) + ->willReturnSelf(); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache + ->expects($this->once()) + ->method('getItem') + ->with(Profile::class) + ->willReturn($item); + $cache + ->expects($this->once()) + ->method('save') + ->with($item) + ->willReturn(true); + + $innerFactory = $this->createMock(SubscriberMetadataFactory::class); + $innerFactory + ->expects($this->once()) + ->method('metadata') + ->with(Profile::class) + ->willReturn($value); + + $factory = new Psr6SubscriberMetadataFactory($innerFactory, $cache); + + self::assertSame($value, $factory->metadata(Profile::class)); + } +} diff --git a/tests/Unit/Metadata/Subscriber/SubscribeMethodMetadataTest.php b/tests/Unit/Metadata/Subscriber/SubscribeMethodMetadataTest.php new file mode 100644 index 000000000..1910f845d --- /dev/null +++ b/tests/Unit/Metadata/Subscriber/SubscribeMethodMetadataTest.php @@ -0,0 +1,33 @@ +name); + self::assertSame([$argument], $metadata->arguments); + } + + public function testInstantiateWithDefaults(): void + { + $metadata = new SubscribeMethodMetadata('onProfileCreated'); + + self::assertSame('onProfileCreated', $metadata->name); + self::assertSame([], $metadata->arguments); + } +} diff --git a/tests/Unit/Projection/ApplyMethodDetectionErrorTest.php b/tests/Unit/Projection/ApplyMethodDetectionErrorTest.php new file mode 100644 index 000000000..2ef4dfcb9 --- /dev/null +++ b/tests/Unit/Projection/ApplyMethodDetectionErrorTest.php @@ -0,0 +1,92 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testMixedApplyAttributeUsage(): void + { + $exception = ApplyMethodDetectionError::mixedApplyAttributeUsage('applyEvent'); + + self::assertSame( + 'The method "applyEvent" has an #[Apply] attribute with an empty "event" argument, but also has a non-empty "event" argument in another #[Apply] attribute. This is not allowed.', + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testArgumentTypeIsNotAClass(): void + { + $exception = ApplyMethodDetectionError::argumentTypeIsNotAClass('applyEvent', 'foo'); + + self::assertSame( + 'The method "applyEvent" has an #[Apply] attribute with an "event" argument that is not a class: string.', + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testDuplicateApplyMethod(): void + { + $exception = ApplyMethodDetectionError::duplicateApplyMethod( + IncrementProjection::class, + ProfileCreated::class, + 'applyA', + 'applyB', + ); + + self::assertSame( + sprintf( + 'Two methods "applyA" and "applyB" on the class "%s" want to apply the same event "%s". Only one method can apply an event.', + IncrementProjection::class, + ProfileCreated::class, + ), + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testParameterIsMissing(): void + { + $exception = ApplyMethodDetectionError::parameterIsMissing('applyEvent', 1); + + self::assertSame( + 'The method "applyEvent" has an #[Apply] attribute with an "event" argument, but the parameter #1 is missing.', + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testArgumentTypeIsMissing(): void + { + $exception = ApplyMethodDetectionError::argumentTypeIsMissing('applyEvent'); + + self::assertSame( + 'The method "applyEvent" has an #[Apply] attribute with an empty "event" argument. This is not allowed.', + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Projection/BasicProjectionTest.php b/tests/Unit/Projection/BasicProjectionTest.php new file mode 100644 index 000000000..12e41ca46 --- /dev/null +++ b/tests/Unit/Projection/BasicProjectionTest.php @@ -0,0 +1,389 @@ + */ + protected function tagFilter(): array + { + return ['match']; + } + + #[Apply] + public function applyProfileCreated(int $state, ProfileCreated $event): int + { + return $state + 1; + } + }; + + $state = $projection->apply(0, $this->message(['match'])); + + self::assertSame(1, $state); + self::assertEquals( + new SubQuery(['match'], [ProfileCreated::class]), + $projection->subQuery(), + ); + self::assertSame($projection->subQuery(), $projection->subQuery()); + } + + public function testApplyWithNonMatchingMessage(): void + { + $projection = new class extends BasicProjection { + public function initialState(): int + { + return 0; + } + + /** @return list */ + protected function tagFilter(): array + { + return ['match']; + } + + #[Apply] + public function applyProfileCreated(int $state, ProfileCreated $event): int + { + return $state + 1; + } + }; + + self::assertSame(0, $projection->apply(0, $this->message(['other']))); + } + + public function testApplyWithUnhandledEvent(): void + { + $projection = new class extends BasicProjection { + public function initialState(): int + { + return 0; + } + + /** @return list */ + protected function tagFilter(): array + { + return []; + } + + /** @return list */ + protected function eventTypeFilter(): array + { + return []; + } + + #[Apply] + public function applyProfileVisited(int $state, ProfileVisited $event): int + { + return $state + 1; + } + }; + + self::assertSame(0, $projection->apply(0, $this->message([]))); + } + + public function testApplyWithExplicitEventClass(): void + { + $projection = new class extends BasicProjection { + public function initialState(): int + { + return 0; + } + + /** @return list */ + protected function tagFilter(): array + { + return []; + } + + #[Apply(ProfileCreated::class)] + public function applyProfileCreated(int $state, object $event): int + { + return $state + 1; + } + }; + + self::assertSame(1, $projection->apply(0, $this->message([]))); + } + + public function testApplyWithUnionType(): void + { + $projection = new class extends BasicProjection { + public function initialState(): int + { + return 0; + } + + /** @return list */ + protected function tagFilter(): array + { + return []; + } + + #[Apply] + public function applyEvent(int $state, ProfileCreated|ProfileVisited $event): int + { + return $state + 1; + } + }; + + self::assertEquals( + new SubQuery([], [ProfileCreated::class, ProfileVisited::class]), + $projection->subQuery(), + ); + self::assertSame(1, $projection->apply(0, $this->message([]))); + } + + public function testDuplicateEmptyApplyAttribute(): void + { + $projection = new class extends BasicProjection { + public function initialState(): int + { + return 0; + } + + /** @return list */ + protected function tagFilter(): array + { + return []; + } + + #[Apply] + #[Apply] + public function applyEvent(int $state, ProfileCreated $event): int + { + return $state; + } + }; + + $this->expectException(ApplyMethodDetectionError::class); + + $projection->subQuery(); + } + + public function testMixedApplyAttributeUsage(): void + { + $projection = new class extends BasicProjection { + public function initialState(): int + { + return 0; + } + + /** @return list */ + protected function tagFilter(): array + { + return []; + } + + #[Apply] + #[Apply(ProfileVisited::class)] + public function applyEvent(int $state, ProfileCreated $event): int + { + return $state; + } + }; + + $this->expectException(ApplyMethodDetectionError::class); + + $projection->subQuery(); + } + + public function testApplyTypeIsNotAClass(): void + { + $projection = new class extends BasicProjection { + public function initialState(): int + { + return 0; + } + + /** @return list */ + protected function tagFilter(): array + { + return []; + } + + #[Apply] + public function applyEvent(int $state, string $event): int + { + return $state; + } + }; + + $this->expectException(ApplyMethodDetectionError::class); + + $projection->subQuery(); + } + + public function testDuplicateApplyMethod(): void + { + $projection = new class extends BasicProjection { + public function initialState(): int + { + return 0; + } + + /** @return list */ + protected function tagFilter(): array + { + return []; + } + + #[Apply] + public function applyA(int $state, ProfileCreated $event): int + { + return $state; + } + + #[Apply] + public function applyB(int $state, ProfileCreated $event): int + { + return $state; + } + }; + + $this->expectException(ApplyMethodDetectionError::class); + + $projection->subQuery(); + } + + public function testParameterIsMissing(): void + { + $projection = new class extends BasicProjection { + public function initialState(): int + { + return 0; + } + + /** @return list */ + protected function tagFilter(): array + { + return []; + } + + #[Apply] + public function applyEvent(int $state): int + { + return $state; + } + }; + + $this->expectException(ApplyMethodDetectionError::class); + + $projection->subQuery(); + } + + public function testUntypedParameter(): void + { + $projection = new class extends BasicProjection { + public function initialState(): int + { + return 0; + } + + /** @return list */ + protected function tagFilter(): array + { + return []; + } + + // phpcs:disable + /** @phpstan-ignore-next-line */ + #[Apply] + public function applyEvent(int $state, $event): int + { + return $state; + } + // phpcs:enable + }; + + $this->expectException(ApplyMethodDetectionError::class); + + $projection->subQuery(); + } + + public function testIntersectionType(): void + { + $projection = new class extends BasicProjection { + public function initialState(): int + { + return 0; + } + + /** @return list */ + protected function tagFilter(): array + { + return []; + } + + #[Apply] + public function applyEvent(int $state, Countable&Stringable $event): int + { + return $state; + } + }; + + $this->expectException(ApplyMethodDetectionError::class); + + $projection->subQuery(); + } + + public function testUnionWithIntersectionType(): void + { + $projection = new class extends BasicProjection { + public function initialState(): int + { + return 0; + } + + /** @return list */ + protected function tagFilter(): array + { + return []; + } + + #[Apply] + public function applyEvent(int $state, ProfileCreated|(Countable&Stringable) $event): int + { + return $state; + } + }; + + $this->expectException(ApplyMethodDetectionError::class); + + $projection->subQuery(); + } + + /** @param list $tags */ + private function message(array $tags): Message + { + return Message::create(new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('foo@bar.com'), + ))->withHeader(new TagsHeader($tags)); + } +} diff --git a/tests/Unit/Projection/CompositeProjectionTest.php b/tests/Unit/Projection/CompositeProjectionTest.php index 76484ab4c..4d965d3f0 100644 --- a/tests/Unit/Projection/CompositeProjectionTest.php +++ b/tests/Unit/Projection/CompositeProjectionTest.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Projection\CompositeProjection; +use Patchlevel\EventSourcing\Projection\Projection; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Header\TagsHeader; use Patchlevel\EventSourcing\Store\Query; @@ -98,4 +99,16 @@ public function testApplySkipsNonMatchingProjection(): void self::assertSame(3, $newState['m']); self::assertSame(3, $newState['n']); } + + public function testQueryWithoutSubQueryProvider(): void + { + $projection = $this->createMock(Projection::class); + + $composite = new CompositeProjection([ + 'a' => $projection, + 'b' => new IncrementProjection(0, ['tag:b']), + ]); + + self::assertEquals(new Query(), $composite->query()); + } } diff --git a/tests/Unit/Projection/StoreProjectionBuilderTest.php b/tests/Unit/Projection/StoreProjectionBuilderTest.php new file mode 100644 index 000000000..509e41465 --- /dev/null +++ b/tests/Unit/Projection/StoreProjectionBuilderTest.php @@ -0,0 +1,45 @@ + new IncrementProjection(0, ['match'])]; + + $expectedQuery = (new CompositeProjection($projections))->query(); + + $message = Message::create(new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('foo@bar.com'), + ))->withHeader(new TagsHeader(['match'])); + + $store = $this->createMock(AppendStore::class); + $store + ->expects($this->once()) + ->method('query') + ->with($expectedQuery) + ->willReturn(new Stream([$message, $message])); + + $builder = new StoreProjectionBuilder($store); + + self::assertSame(['count' => 2], $builder->build($projections)); + } +} diff --git a/tests/Unit/QueryBus/ChainHandlerProviderTest.php b/tests/Unit/QueryBus/ChainHandlerProviderTest.php index 0919c289a..03bb33464 100644 --- a/tests/Unit/QueryBus/ChainHandlerProviderTest.php +++ b/tests/Unit/QueryBus/ChainHandlerProviderTest.php @@ -8,9 +8,10 @@ use Patchlevel\EventSourcing\QueryBus\HandlerDescriptor; use Patchlevel\EventSourcing\QueryBus\HandlerProvider; use Patchlevel\EventSourcing\Tests\Unit\Fixture\QueryProfile; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** @covers \Patchlevel\EventSourcing\QueryBus\ChainHandlerProvider */ +#[CoversClass(ChainHandlerProvider::class)] final class ChainHandlerProviderTest extends TestCase { public function testEmpty(): void diff --git a/tests/Unit/QueryBus/HandlerDescriptorTest.php b/tests/Unit/QueryBus/HandlerDescriptorTest.php index 6fa2ad26a..f3af55b27 100644 --- a/tests/Unit/QueryBus/HandlerDescriptorTest.php +++ b/tests/Unit/QueryBus/HandlerDescriptorTest.php @@ -9,10 +9,11 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\QueryAnsweringProjection; use Patchlevel\EventSourcing\Tests\Unit\Fixture\QueryAnsweringStaticProjection; use Patchlevel\EventSourcing\Tests\Unit\Fixture\QueryProfile; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\TestCase; -/** @covers \Patchlevel\EventSourcing\QueryBus\HandlerDescriptor */ +#[CoversClass(HandlerDescriptor::class)] final class HandlerDescriptorTest extends TestCase { public function testObjectMethod(): void diff --git a/tests/Unit/QueryBus/HandlerFinderTest.php b/tests/Unit/QueryBus/HandlerFinderTest.php index 29c948433..e357f7d65 100644 --- a/tests/Unit/QueryBus/HandlerFinderTest.php +++ b/tests/Unit/QueryBus/HandlerFinderTest.php @@ -10,9 +10,10 @@ use Patchlevel\EventSourcing\QueryBus\InvalidHandleMethod; use Patchlevel\EventSourcing\Tests\Unit\Fixture\OtherQueryProfile; use Patchlevel\EventSourcing\Tests\Unit\Fixture\QueryProfile; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** @covers \Patchlevel\EventSourcing\QueryBus\HandlerFinder */ +#[CoversClass(HandlerFinder::class)] final class HandlerFinderTest extends TestCase { public function testNoParameters(): void @@ -48,6 +49,38 @@ public function handle(mixed $query): void self::assertSame([], $result); } + public function testSkipMethodWithoutAnswerAttribute(): void + { + $class = new class () { + public function handle(QueryProfile $query): void + { + } + }; + + $result = [...HandlerFinder::findInClass($class::class)]; + + self::assertSame([], $result); + } + + public function testMissingType(): void + { + $this->expectException(InvalidHandleMethod::class); + + $class = new class () { + // phpcs:disable + /** @phpstan-ignore-next-line */ + #[Answer] + public function handle($query): void + { + } + // phpcs:enable + }; + + $result = [...HandlerFinder::findInClass($class::class)]; + + self::assertSame([], $result); + } + public function testWrongType(): void { $this->expectException(InvalidHandleMethod::class); diff --git a/tests/Unit/QueryBus/HandlerReferenceTest.php b/tests/Unit/QueryBus/HandlerReferenceTest.php new file mode 100644 index 000000000..236f2e751 --- /dev/null +++ b/tests/Unit/QueryBus/HandlerReferenceTest.php @@ -0,0 +1,23 @@ +queryClass); + self::assertSame('handle', $reference->method); + self::assertTrue($reference->static); + } +} diff --git a/tests/Unit/QueryBus/InvalidHandleMethodTest.php b/tests/Unit/QueryBus/InvalidHandleMethodTest.php new file mode 100644 index 000000000..6cfac358d --- /dev/null +++ b/tests/Unit/QueryBus/InvalidHandleMethodTest.php @@ -0,0 +1,38 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testIncompatibleType(): void + { + $exception = InvalidHandleMethod::incompatibleType(QueryProfile::class, 'handle'); + + self::assertSame( + sprintf('Query handling method "handle" in class "%s" has no compatible type', QueryProfile::class), + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/QueryBus/InvalidQueryHandlerTest.php b/tests/Unit/QueryBus/InvalidQueryHandlerTest.php new file mode 100644 index 000000000..a04c95313 --- /dev/null +++ b/tests/Unit/QueryBus/InvalidQueryHandlerTest.php @@ -0,0 +1,38 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testMultipleHandler(): void + { + $exception = InvalidQueryHandler::multipleHandler(QueryProfile::class); + + self::assertSame( + sprintf('Multiple handlers found for query %s', QueryProfile::class), + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/QueryBus/ServiceHandlerProviderTest.php b/tests/Unit/QueryBus/ServiceHandlerProviderTest.php index 2854be51c..c246a5df7 100644 --- a/tests/Unit/QueryBus/ServiceHandlerProviderTest.php +++ b/tests/Unit/QueryBus/ServiceHandlerProviderTest.php @@ -7,9 +7,10 @@ use Patchlevel\EventSourcing\Attribute\Answer; use Patchlevel\EventSourcing\QueryBus\ServiceHandlerProvider; use Patchlevel\EventSourcing\Tests\Unit\Fixture\QueryProfile; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** @covers \Patchlevel\EventSourcing\QueryBus\ServiceHandlerProvider */ +#[CoversClass(ServiceHandlerProvider::class)] final class ServiceHandlerProviderTest extends TestCase { public function testEmpty(): void diff --git a/tests/Unit/QueryBus/SyncQueryBusTest.php b/tests/Unit/QueryBus/SyncQueryBusTest.php index bae242f8e..01e99320d 100644 --- a/tests/Unit/QueryBus/SyncQueryBusTest.php +++ b/tests/Unit/QueryBus/SyncQueryBusTest.php @@ -8,10 +8,11 @@ use Patchlevel\EventSourcing\QueryBus\HandlerProvider; use Patchlevel\EventSourcing\QueryBus\InvalidQueryHandler; use Patchlevel\EventSourcing\QueryBus\SyncQueryBus; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -/** @covers \Patchlevel\EventSourcing\QueryBus\SyncQueryBus */ +#[CoversClass(SyncQueryBus::class)] final class SyncQueryBusTest extends TestCase { public function testHandlerNotFound(): void @@ -76,4 +77,62 @@ public function __invoke(object $query): void self::assertSame($query, $handler->query); } + + public function testIterableHandlerProviders(): void + { + $query = new class { + }; + + $handler = new class { + public object|null $query = null; + + public function __invoke(object $query): void + { + $this->query = $query; + } + }; + + $handlerProvider = $this->createMock(HandlerProvider::class); + $handlerProvider + ->expects($this->once()) + ->method('handlerForQuery') + ->with($query::class) + ->willReturn([ + new HandlerDescriptor($handler), + ]); + + $queryBus = new SyncQueryBus([$handlerProvider]); + $queryBus->dispatch($query); + + self::assertSame($query, $handler->query); + } + + public function testHandlerGenerator(): void + { + $query = new class { + }; + + $handler = new class { + public object|null $query = null; + + public function __invoke(object $query): void + { + $this->query = $query; + } + }; + + $handlerProvider = $this->createMock(HandlerProvider::class); + $handlerProvider + ->expects($this->once()) + ->method('handlerForQuery') + ->with($query::class) + ->willReturnCallback(static function () use ($handler) { + yield new HandlerDescriptor($handler); + }); + + $queryBus = new SyncQueryBus($handlerProvider); + $queryBus->dispatch($query); + + self::assertSame($query, $handler->query); + } } diff --git a/tests/Unit/Repository/AggregateAlreadyExistsTest.php b/tests/Unit/Repository/AggregateAlreadyExistsTest.php new file mode 100644 index 000000000..5e8d965f2 --- /dev/null +++ b/tests/Unit/Repository/AggregateAlreadyExistsTest.php @@ -0,0 +1,28 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Repository/AggregateDetachedTest.php b/tests/Unit/Repository/AggregateDetachedTest.php new file mode 100644 index 000000000..9d4278239 --- /dev/null +++ b/tests/Unit/Repository/AggregateDetachedTest.php @@ -0,0 +1,28 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Repository/AggregateNotFoundTest.php b/tests/Unit/Repository/AggregateNotFoundTest.php new file mode 100644 index 000000000..c74f5a12e --- /dev/null +++ b/tests/Unit/Repository/AggregateNotFoundTest.php @@ -0,0 +1,28 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Repository/AggregateUnknownTest.php b/tests/Unit/Repository/AggregateUnknownTest.php new file mode 100644 index 000000000..999dbac8a --- /dev/null +++ b/tests/Unit/Repository/AggregateUnknownTest.php @@ -0,0 +1,28 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Repository/DefaultRepositoryTest.php b/tests/Unit/Repository/DefaultRepositoryTest.php index 772ceb1dd..0b7e4a428 100644 --- a/tests/Unit/Repository/DefaultRepositoryTest.php +++ b/tests/Unit/Repository/DefaultRepositoryTest.php @@ -15,11 +15,14 @@ use Patchlevel\EventSourcing\Repository\AggregateOutdated; use Patchlevel\EventSourcing\Repository\AggregateUnknown; use Patchlevel\EventSourcing\Repository\DefaultRepository; +use Patchlevel\EventSourcing\Repository\InvalidAggregate; use Patchlevel\EventSourcing\Repository\MessageDecorator\MessageDecorator; use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamDecorator; +use Patchlevel\EventSourcing\Repository\PlayheadMismatch; use Patchlevel\EventSourcing\Repository\WrongAggregate; use Patchlevel\EventSourcing\Snapshot\SnapshotNotFound; use Patchlevel\EventSourcing\Snapshot\SnapshotStore; +use Patchlevel\EventSourcing\Snapshot\SnapshotVersionInvalid; use Patchlevel\EventSourcing\Store\ArchivedHeader; use Patchlevel\EventSourcing\Store\Criteria\ArchivedCriterion; use Patchlevel\EventSourcing\Store\Criteria\Criteria; @@ -30,12 +33,16 @@ use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; +use Patchlevel\EventSourcing\Tests\ReturnCallback; use Patchlevel\EventSourcing\Tests\Unit\Fixture\AutoInitializableProfile; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\BrokenAutoInitializableProfile; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\NameChanged; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileVisited; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithBrokenPlayhead; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithSnapshot; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithStream; use PHPUnit\Framework\Attributes\CoversClass; @@ -764,4 +771,203 @@ public function testLoadInitializableAggregate(): void self::assertSame(1, $aggregate->playhead()); self::assertEquals(ProfileId::fromString('1'), $aggregate->id()); } + + public function testLoadInitializableAggregateWithInvalidReturn(): void + { + $store = $this->createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->willReturn(new Stream([])); + + $repository = new DefaultRepository($store, BrokenAutoInitializableProfile::metadata()); + + $this->expectException(InvalidAggregate::class); + + $repository->load(ProfileId::fromString('1')); + } + + public function testLoadAggregateWithSnapshotRebuildFailed(): void + { + $id = ProfileId::fromString('1'); + + $profile = ProfileWithSnapshot::createProfile( + $id, + Email::fromString('hallo@patchlevel.de'), + ); + + $badMessage = Message::create(new NameChanged('foo')) + ->withHeader(new StreamNameHeader('profile_with_snapshot-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable())); + + $goodMessage = Message::create( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hallo@patchlevel.de'), + ), + ) + ->withHeader(new StreamNameHeader('profile_with_snapshot-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable())); + + $store = $this->createMock(Store::class); + $store + ->expects($this->exactly(2)) + ->method('load') + ->willReturnCallback(new ReturnCallback([ + [ + [ + new Criteria( + new StreamCriterion('profile_with_snapshot-1'), + new FromPlayheadCriterion(1), + ), + null, + null, + false, + ], + new Stream([$badMessage]), + ], + [ + [ + new Criteria( + new StreamCriterion('profile_with_snapshot-1'), + new ArchivedCriterion(false), + ), + null, + null, + false, + ], + new Stream([$goodMessage]), + ], + ])); + + $snapshotStore = $this->createMock(SnapshotStore::class); + $snapshotStore + ->expects($this->once()) + ->method('load') + ->with(ProfileWithSnapshot::class, $id) + ->willReturn($profile); + $snapshotStore + ->expects($this->never()) + ->method('save'); + + $repository = new DefaultRepository( + $store, + ProfileWithSnapshot::metadata(), + null, + $snapshotStore, + ); + + $aggregate = $repository->load($id); + + self::assertInstanceOf(ProfileWithSnapshot::class, $aggregate); + self::assertSame(1, $aggregate->playhead()); + self::assertEquals(Email::fromString('hallo@patchlevel.de'), $aggregate->email()); + } + + public function testLoadAggregateWithSnapshotVersionInvalid(): void + { + $store = $this->createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->with(new Criteria( + new StreamCriterion('profile_with_snapshot-1'), + new ArchivedCriterion(false), + )) + ->willReturn( + new Stream([ + Message::create( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hallo@patchlevel.de'), + ), + ) + ->withHeader(new StreamNameHeader('profile_with_snapshot-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable())), + Message::create( + new ProfileVisited( + ProfileId::fromString('1'), + ), + ) + ->withHeader(new StreamNameHeader('profile_with_snapshot-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable())), + Message::create( + new ProfileVisited( + ProfileId::fromString('1'), + ), + ) + ->withHeader(new StreamNameHeader('profile_with_snapshot-1')) + ->withHeader(new PlayheadHeader(3)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable())), + ]), + ); + + $snapshotStore = $this->createMock(SnapshotStore::class); + $snapshotStore + ->expects($this->once()) + ->method('load') + ->with( + ProfileWithSnapshot::class, + ProfileId::fromString('1'), + ) + ->willThrowException(new SnapshotVersionInvalid('profile_with_snapshot-1')); + + $snapshotStore + ->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(ProfileWithSnapshot::class)); + + $repository = new DefaultRepository( + $store, + ProfileWithSnapshot::metadata(), + null, + $snapshotStore, + ); + + $aggregate = $repository->load(ProfileId::fromString('1')); + + self::assertInstanceOf(ProfileWithSnapshot::class, $aggregate); + self::assertSame(3, $aggregate->playhead()); + self::assertEquals(ProfileId::fromString('1'), $aggregate->id()); + } + + public function testSavePlayheadMismatch(): void + { + $store = $this->createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->willReturn( + new Stream([ + Message::create( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('hallo@patchlevel.de'), + ), + ) + ->withHeader(new StreamNameHeader('profile_with_broken_playhead-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable())), + ]), + ); + $store + ->expects($this->never()) + ->method('save'); + + $repository = new DefaultRepository($store, ProfileWithBrokenPlayhead::metadata()); + + $aggregate = $repository->load(ProfileId::fromString('1')); + + self::assertInstanceOf(ProfileWithBrokenPlayhead::class, $aggregate); + + $aggregate->visit(); + + $this->expectException(PlayheadMismatch::class); + + $repository->save($aggregate); + } } diff --git a/tests/Unit/Repository/InvalidAggregateTest.php b/tests/Unit/Repository/InvalidAggregateTest.php new file mode 100644 index 000000000..1e73443bf --- /dev/null +++ b/tests/Unit/Repository/InvalidAggregateTest.php @@ -0,0 +1,28 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Repository/MessageDecorator/EventTagDecoratorTest.php b/tests/Unit/Repository/MessageDecorator/EventTagDecoratorTest.php new file mode 100644 index 000000000..68e5dbd7e --- /dev/null +++ b/tests/Unit/Repository/MessageDecorator/EventTagDecoratorTest.php @@ -0,0 +1,42 @@ +createMock(EventTagExtractor::class); + $extractor + ->expects($this->once()) + ->method('extract') + ->with($event) + ->willReturn(['profile-1']); + + $decorator = new EventTagDecorator($extractor); + + $decoratedMessage = $decorator($message); + + self::assertSame(['profile-1'], $decoratedMessage->header(TagsHeader::class)->tags); + } +} diff --git a/tests/Unit/Repository/PlayheadMismatchTest.php b/tests/Unit/Repository/PlayheadMismatchTest.php new file mode 100644 index 000000000..65068643a --- /dev/null +++ b/tests/Unit/Repository/PlayheadMismatchTest.php @@ -0,0 +1,28 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Repository/SnapshotRebuildFailedTest.php b/tests/Unit/Repository/SnapshotRebuildFailedTest.php new file mode 100644 index 000000000..117307fe4 --- /dev/null +++ b/tests/Unit/Repository/SnapshotRebuildFailedTest.php @@ -0,0 +1,35 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + self::assertSame($previous, $exception->getPrevious()); + self::assertSame(Profile::class, $exception->aggregateClass()); + self::assertSame($id, $exception->aggregateRootId()); + } +} diff --git a/tests/Unit/Repository/WrongAggregateTest.php b/tests/Unit/Repository/WrongAggregateTest.php new file mode 100644 index 000000000..fd9964cff --- /dev/null +++ b/tests/Unit/Repository/WrongAggregateTest.php @@ -0,0 +1,28 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Schema/DoctrineHelperTest.php b/tests/Unit/Schema/DoctrineHelperTest.php new file mode 100644 index 000000000..9647ea345 --- /dev/null +++ b/tests/Unit/Schema/DoctrineHelperTest.php @@ -0,0 +1,137 @@ +createMock(Connection::class); + $connection + ->expects($this->never()) + ->method('getParams'); + + self::assertTrue(DoctrineHelper::sameDatabase($connection, $connection)); + } + + public function testSameParams(): void + { + $connectionA = $this->createMock(Connection::class); + $connectionA + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db']); + $connectionA + ->expects($this->never()) + ->method('executeStatement'); + + $connectionB = $this->createMock(Connection::class); + $connectionB + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db']); + $connectionB + ->expects($this->never()) + ->method('executeStatement'); + + self::assertTrue(DoctrineHelper::sameDatabase($connectionA, $connectionB)); + } + + public function testDifferentDatabase(): void + { + $connectionA = $this->createMock(Connection::class); + $connectionA + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db']); + $connectionA + ->expects($this->exactly(2)) + ->method('executeStatement') + ->with($this->matchesRegularExpression('/^(CREATE|DROP) TABLE same_db_check_[0-9a-f]{14}/')) + ->willReturn(1); + + $connectionB = $this->createMock(Connection::class); + $connectionB + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db2']); + $connectionB + ->expects($this->once()) + ->method('executeStatement') + ->with($this->matchesRegularExpression('/^DROP TABLE same_db_check_[0-9a-f]{14}/')) + ->willThrowException(new TableNotFoundException(new Exception('table not found'), null)); + + self::assertFalse(DoctrineHelper::sameDatabase($connectionA, $connectionB)); + } + + public function testSameDatabaseDetectedViaCheckTable(): void + { + $connectionA = $this->createMock(Connection::class); + $connectionA + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db', 'host' => 'a']); + $connectionA + ->expects($this->exactly(2)) + ->method('executeStatement') + ->willReturnCallback(static function (string $sql): int { + if (str_starts_with($sql, 'CREATE TABLE same_db_check_')) { + return 1; + } + + throw new TableNotFoundException(new Exception('table not found'), null); + }); + + $connectionB = $this->createMock(Connection::class); + $connectionB + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db', 'host' => 'b']); + $connectionB + ->expects($this->once()) + ->method('executeStatement') + ->with($this->matchesRegularExpression('/^DROP TABLE same_db_check_[0-9a-f]{14}/')) + ->willReturn(1); + + self::assertTrue(DoctrineHelper::sameDatabase($connectionA, $connectionB)); + } + + public function testIgnoresErrorOnSecondConnectionDrop(): void + { + $connectionA = $this->createMock(Connection::class); + $connectionA + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db']); + $connectionA + ->expects($this->exactly(2)) + ->method('executeStatement') + ->with($this->matchesRegularExpression('/^(CREATE|DROP) TABLE same_db_check_[0-9a-f]{14}/')) + ->willReturn(1); + + $connectionB = $this->createMock(Connection::class); + $connectionB + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db2']); + $connectionB + ->expects($this->once()) + ->method('executeStatement') + ->willThrowException(new RuntimeException('connection error')); + + self::assertFalse(DoctrineHelper::sameDatabase($connectionA, $connectionB)); + } +} diff --git a/tests/Unit/Serializer/AttributeEventTagExtractorTest.php b/tests/Unit/Serializer/AttributeEventTagExtractorTest.php index bdd4b19df..a12901fbc 100644 --- a/tests/Unit/Serializer/AttributeEventTagExtractorTest.php +++ b/tests/Unit/Serializer/AttributeEventTagExtractorTest.php @@ -7,8 +7,14 @@ use Patchlevel\EventSourcing\Attribute\EventTag; use Patchlevel\EventSourcing\Identifier\CustomId; use Patchlevel\EventSourcing\Serializer\AttributeEventTagExtractor; +use Patchlevel\EventSourcing\Serializer\EventTagExtractorError; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Stringable; +use function sprintf; + +#[CoversClass(AttributeEventTagExtractor::class)] final class AttributeEventTagExtractorTest extends TestCase { public function testExtractEmpty(): void @@ -91,4 +97,69 @@ public function __construct( self::assertSame(['name:0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33'], $tags); } + + public function testExtractInt(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class (42) { + public function __construct( + #[EventTag] + public int $number, + ) { + } + }; + + $tags = $extractor->extract($event); + + self::assertSame(['42'], $tags); + } + + public function testExtractRealStringable(): void + { + $extractor = new AttributeEventTagExtractor(); + + $stringable = new class implements Stringable { + public function __toString(): string + { + return 'foo'; + } + }; + + $event = new class ($stringable) { + public function __construct( + #[EventTag] + public Stringable $id, + ) { + } + }; + + $tags = $extractor->extract($event); + + self::assertSame(['foo'], $tags); + } + + public function testExtractInvalidValueType(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class ([]) { + /** @param list $items */ + public function __construct( + #[EventTag] + public array $items, + ) { + } + }; + + $this->expectException(EventTagExtractorError::class); + $this->expectExceptionMessage( + sprintf( + 'Event tag value for property "items" in class "%s" must be stringable, array given', + $event::class, + ), + ); + + $extractor->extract($event); + } } diff --git a/tests/Unit/Serializer/EventTagExtractorErrorTest.php b/tests/Unit/Serializer/EventTagExtractorErrorTest.php new file mode 100644 index 000000000..68863e56f --- /dev/null +++ b/tests/Unit/Serializer/EventTagExtractorErrorTest.php @@ -0,0 +1,30 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php b/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php index 720976f7e..ac2dd3dc4 100644 --- a/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php +++ b/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php @@ -94,4 +94,22 @@ public function testAutoDetectMissingTypeBecauseNull(): void $normalizer->identifierClass(); } + + public function testAutoDetectNullableType(): void + { + $normalizer = new IdNormalizer(); + $normalizer->handleType(Type::nullable(Type::object(ProfileId::class))); + + self::assertEquals(ProfileId::class, $normalizer->identifierClass()); + } + + public function testAutoDetectNotObjectType(): void + { + $this->expectException(InvalidType::class); + + $normalizer = new IdNormalizer(); + $normalizer->handleType(Type::string()); + + $normalizer->identifierClass(); + } } diff --git a/tests/Unit/Snapshot/DefaultSnapshotStoreTest.php b/tests/Unit/Snapshot/DefaultSnapshotStoreTest.php index a97cc1267..da44ebabf 100644 --- a/tests/Unit/Snapshot/DefaultSnapshotStoreTest.php +++ b/tests/Unit/Snapshot/DefaultSnapshotStoreTest.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Snapshot\Adapter\SnapshotAdapter; use Patchlevel\EventSourcing\Snapshot\AdapterNotFound; +use Patchlevel\EventSourcing\Snapshot\AdapterRepository; use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; use Patchlevel\EventSourcing\Snapshot\SnapshotNotConfigured; use Patchlevel\EventSourcing\Snapshot\SnapshotNotFound; @@ -127,4 +128,29 @@ public function testGetAdapter(): void self::assertSame($adapter, $store->adapter(ProfileWithSnapshot::class)); } + + public function testAdapterRepositoryInstance(): void + { + $adapter = $this->createMock(SnapshotAdapter::class); + + $adapterRepository = $this->createMock(AdapterRepository::class); + $adapterRepository + ->expects($this->once()) + ->method('get') + ->with('memory') + ->willReturn($adapter); + + $store = new DefaultSnapshotStore($adapterRepository); + + self::assertSame($adapter, $store->adapter(ProfileWithSnapshot::class)); + } + + public function testCreateDefault(): void + { + $adapter = $this->createMock(SnapshotAdapter::class); + + $store = DefaultSnapshotStore::createDefault(['memory' => $adapter]); + + self::assertSame($adapter, $store->adapter(ProfileWithSnapshot::class)); + } } diff --git a/tests/Unit/Store/AppendConditionNotMetTest.php b/tests/Unit/Store/AppendConditionNotMetTest.php new file mode 100644 index 000000000..c3f666da9 --- /dev/null +++ b/tests/Unit/Store/AppendConditionNotMetTest.php @@ -0,0 +1,26 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + self::assertSame($condition, $exception->appendCondition); + } +} diff --git a/tests/Unit/Store/AppendConditionTest.php b/tests/Unit/Store/AppendConditionTest.php new file mode 100644 index 000000000..132b1f4c8 --- /dev/null +++ b/tests/Unit/Store/AppendConditionTest.php @@ -0,0 +1,33 @@ +query); + self::assertSame(42, $condition->highestSequenceNumber); + } + + public function testInstantiateWithDefaults(): void + { + $condition = new AppendCondition(); + + self::assertEquals(new Query(), $condition->query); + self::assertNull($condition->highestSequenceNumber); + } +} diff --git a/tests/Unit/Store/Criteria/ArchivedCriterionTest.php b/tests/Unit/Store/Criteria/ArchivedCriterionTest.php new file mode 100644 index 000000000..cac7b6819 --- /dev/null +++ b/tests/Unit/Store/Criteria/ArchivedCriterionTest.php @@ -0,0 +1,20 @@ +archived); + } +} diff --git a/tests/Unit/Store/Crtieria/CriteriaBuilderTest.php b/tests/Unit/Store/Criteria/CriteriaBuilderTest.php similarity index 58% rename from tests/Unit/Store/Crtieria/CriteriaBuilderTest.php rename to tests/Unit/Store/Criteria/CriteriaBuilderTest.php index aba1feb2e..6f687a49f 100644 --- a/tests/Unit/Store/Crtieria/CriteriaBuilderTest.php +++ b/tests/Unit/Store/Criteria/CriteriaBuilderTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Unit\Store\Crtieria; +namespace Patchlevel\EventSourcing\Tests\Unit\Store\Criteria; use Patchlevel\EventSourcing\Store\Criteria\ArchivedCriterion; use Patchlevel\EventSourcing\Store\Criteria\Criteria; @@ -11,6 +11,7 @@ use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromPlayheadCriterion; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; +use Patchlevel\EventSourcing\Store\Criteria\ToPlayheadCriterion; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -47,4 +48,41 @@ public function testFull(): void $criteria, ); } + + public function testStreamNames(): void + { + $builder = new CriteriaBuilder(); + $criteria = $builder + ->streamName(['profile-1', 'profile-2']) + ->build(); + + self::assertEquals( + new Criteria(new StreamCriterion('profile-1', 'profile-2')), + $criteria, + ); + } + + public function testResetStreamName(): void + { + $builder = new CriteriaBuilder(); + $criteria = $builder + ->streamName('profile-1') + ->streamName(null) + ->build(); + + self::assertEquals(new Criteria(), $criteria); + } + + public function testToPlayhead(): void + { + $builder = new CriteriaBuilder(); + $criteria = $builder + ->toPlayhead(10) + ->build(); + + self::assertEquals( + new Criteria(new ToPlayheadCriterion(10)), + $criteria, + ); + } } diff --git a/tests/Unit/Store/Crtieria/CriteriaTest.php b/tests/Unit/Store/Criteria/CriteriaTest.php similarity index 97% rename from tests/Unit/Store/Crtieria/CriteriaTest.php rename to tests/Unit/Store/Criteria/CriteriaTest.php index 728c94c6e..1b27676da 100644 --- a/tests/Unit/Store/Crtieria/CriteriaTest.php +++ b/tests/Unit/Store/Criteria/CriteriaTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Unit\Store\Crtieria; +namespace Patchlevel\EventSourcing\Tests\Unit\Store\Criteria; use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\CriterionNotFound; diff --git a/tests/Unit/Store/Criteria/CriterionNotFoundTest.php b/tests/Unit/Store/Criteria/CriterionNotFoundTest.php new file mode 100644 index 000000000..9450ee870 --- /dev/null +++ b/tests/Unit/Store/Criteria/CriterionNotFoundTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Store/Criteria/EventIdCriterionTest.php b/tests/Unit/Store/Criteria/EventIdCriterionTest.php new file mode 100644 index 000000000..0dcdb5b6b --- /dev/null +++ b/tests/Unit/Store/Criteria/EventIdCriterionTest.php @@ -0,0 +1,20 @@ +eventId); + } +} diff --git a/tests/Unit/Store/Criteria/EventsCriterionTest.php b/tests/Unit/Store/Criteria/EventsCriterionTest.php new file mode 100644 index 000000000..fe72b131b --- /dev/null +++ b/tests/Unit/Store/Criteria/EventsCriterionTest.php @@ -0,0 +1,20 @@ +events); + } +} diff --git a/tests/Unit/Store/Criteria/FromPlayheadCriterionTest.php b/tests/Unit/Store/Criteria/FromPlayheadCriterionTest.php new file mode 100644 index 000000000..ed392232d --- /dev/null +++ b/tests/Unit/Store/Criteria/FromPlayheadCriterionTest.php @@ -0,0 +1,20 @@ +fromPlayhead); + } +} diff --git a/tests/Unit/Store/Criteria/StreamCriterionTest.php b/tests/Unit/Store/Criteria/StreamCriterionTest.php new file mode 100644 index 000000000..bff3711c8 --- /dev/null +++ b/tests/Unit/Store/Criteria/StreamCriterionTest.php @@ -0,0 +1,35 @@ +streamName); + } + + public function testStartWith(): void + { + $criterion = StreamCriterion::startWith('profile'); + + self::assertSame(['profile*'], $criterion->streamName); + } + + public function testAll(): void + { + self::assertTrue((new StreamCriterion('*'))->all()); + self::assertTrue((new StreamCriterion('profile-1', '*'))->all()); + self::assertFalse((new StreamCriterion('profile-1'))->all()); + self::assertFalse((new StreamCriterion())->all()); + } +} diff --git a/tests/Unit/Store/Criteria/TagCriterionTest.php b/tests/Unit/Store/Criteria/TagCriterionTest.php new file mode 100644 index 000000000..8e756be9a --- /dev/null +++ b/tests/Unit/Store/Criteria/TagCriterionTest.php @@ -0,0 +1,20 @@ +tags); + } +} diff --git a/tests/Unit/Store/Criteria/ToIndexCriterionTest.php b/tests/Unit/Store/Criteria/ToIndexCriterionTest.php new file mode 100644 index 000000000..3619f42b4 --- /dev/null +++ b/tests/Unit/Store/Criteria/ToIndexCriterionTest.php @@ -0,0 +1,20 @@ +toIndex); + } +} diff --git a/tests/Unit/Store/Criteria/ToPlayheadCriterionTest.php b/tests/Unit/Store/Criteria/ToPlayheadCriterionTest.php new file mode 100644 index 000000000..b927ba00c --- /dev/null +++ b/tests/Unit/Store/Criteria/ToPlayheadCriterionTest.php @@ -0,0 +1,20 @@ +toPlayhead); + } +} diff --git a/tests/Unit/Store/Header/EventIdHeaderTest.php b/tests/Unit/Store/Header/EventIdHeaderTest.php new file mode 100644 index 000000000..0fdc3e39c --- /dev/null +++ b/tests/Unit/Store/Header/EventIdHeaderTest.php @@ -0,0 +1,20 @@ +eventId); + } +} diff --git a/tests/Unit/Store/Header/IndexHeaderTest.php b/tests/Unit/Store/Header/IndexHeaderTest.php new file mode 100644 index 000000000..0f560c42a --- /dev/null +++ b/tests/Unit/Store/Header/IndexHeaderTest.php @@ -0,0 +1,20 @@ +index); + } +} diff --git a/tests/Unit/Store/Header/PlayheadHeaderTest.php b/tests/Unit/Store/Header/PlayheadHeaderTest.php new file mode 100644 index 000000000..407dceaba --- /dev/null +++ b/tests/Unit/Store/Header/PlayheadHeaderTest.php @@ -0,0 +1,20 @@ +playhead); + } +} diff --git a/tests/Unit/Store/InMemoryStoreTest.php b/tests/Unit/Store/InMemoryStoreTest.php index 913ece46c..50899728a 100644 --- a/tests/Unit/Store/InMemoryStoreTest.php +++ b/tests/Unit/Store/InMemoryStoreTest.php @@ -554,6 +554,35 @@ public function testRemove(): void self::assertSame([$message1, $message4], $stream->toList()); } + public function testArchive(): void + { + $message1 = (new Message(new ProfileVisited(ProfileId::fromString('1')))) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new EventIdHeader('019aa600-56ef-7ca3-b92a-37c53851e2c2')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable())) + ->withHeader(new IndexHeader(1)); + $message2 = (new Message(new ProfileVisited(ProfileId::fromString('2')))) + ->withHeader(new StreamNameHeader('bar')) + ->withHeader(new EventIdHeader('019aa600-8834-752a-ae2e-d8650e84f403')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable())) + ->withHeader(new IndexHeader(2)); + + $store = new InMemoryStore([$message1, $message2]); + + $store->archive(new Criteria(new StreamCriterion('bar'))); + + $messages = $store->load()->toList(); + + self::assertCount(2, $messages); + self::assertFalse($messages[0]->hasHeader(ArchivedHeader::class)); + self::assertTrue($messages[1]->hasHeader(ArchivedHeader::class)); + + $archivedMessages = $store->load(new Criteria(new ArchivedCriterion(true)))->toList(); + + self::assertCount(1, $archivedMessages); + self::assertSame('bar', $archivedMessages[0]->header(StreamNameHeader::class)->streamName); + } + public function testTransactional(): void { $called = false; diff --git a/tests/Unit/Store/InvalidStreamNameTest.php b/tests/Unit/Store/InvalidStreamNameTest.php new file mode 100644 index 000000000..393c433bc --- /dev/null +++ b/tests/Unit/Store/InvalidStreamNameTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Store/InvalidTypeTest.php b/tests/Unit/Store/InvalidTypeTest.php new file mode 100644 index 000000000..25cc10ab2 --- /dev/null +++ b/tests/Unit/Store/InvalidTypeTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Store/LockingNotImplementedTest.php b/tests/Unit/Store/LockingNotImplementedTest.php new file mode 100644 index 000000000..91505e195 --- /dev/null +++ b/tests/Unit/Store/LockingNotImplementedTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Store/MissingDataForStorageTest.php b/tests/Unit/Store/MissingDataForStorageTest.php new file mode 100644 index 000000000..124b11217 --- /dev/null +++ b/tests/Unit/Store/MissingDataForStorageTest.php @@ -0,0 +1,26 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + self::assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/Unit/Store/MissingEventRegistryTest.php b/tests/Unit/Store/MissingEventRegistryTest.php new file mode 100644 index 000000000..da9e0637f --- /dev/null +++ b/tests/Unit/Store/MissingEventRegistryTest.php @@ -0,0 +1,28 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Store/StoreIsReadOnlyTest.php b/tests/Unit/Store/StoreIsReadOnlyTest.php new file mode 100644 index 000000000..10069fa61 --- /dev/null +++ b/tests/Unit/Store/StoreIsReadOnlyTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Store/StreamDoctrineDbalStoreTest.php b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php index 254d00058..76f0d351c 100644 --- a/tests/Unit/Store/StreamDoctrineDbalStoreTest.php +++ b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php @@ -27,16 +27,24 @@ use Patchlevel\EventSourcing\Message\Serializer\HeadersSerializer; use Patchlevel\EventSourcing\Serializer\EventSerializer; use Patchlevel\EventSourcing\Serializer\SerializedEvent; +use Patchlevel\EventSourcing\Store\ArchivedHeader; +use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\CriteriaBuilder; +use Patchlevel\EventSourcing\Store\Criteria\EventIdCriterion; +use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; +use Patchlevel\EventSourcing\Store\Criteria\ToIndexCriterion; use Patchlevel\EventSourcing\Store\Header\EventIdHeader; +use Patchlevel\EventSourcing\Store\Header\IndexHeader; use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\LockCouldNotBeAcquired; use Patchlevel\EventSourcing\Store\LockCouldNotBeFreed; +use Patchlevel\EventSourcing\Store\LockingNotImplemented; use Patchlevel\EventSourcing\Store\MissingDataForStorage; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; +use Patchlevel\EventSourcing\Store\UnsupportedCriterion; use Patchlevel\EventSourcing\Store\WrongQueryResult; use Patchlevel\EventSourcing\Tests\ReturnCallback; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email; @@ -51,8 +59,11 @@ use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; +use Ramsey\Uuid\Uuid; use RuntimeException; +use stdClass; +use function is_string; use function iterator_to_array; use function method_exists; @@ -1782,4 +1793,1231 @@ public function testConfigureSchema(): void self::assertEquals($expectedSchema, $schema); } + + public function testLoadBackwards(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store ORDER BY id DESC', [], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load(backwards: true); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithEmptyStreamCriterion(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store ORDER BY id ASC', [], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load(new Criteria(new StreamCriterion())); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithToPlayhead(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE playhead < :to_playhead ORDER BY id ASC', ['to_playhead' => 10], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->toPlayhead(10) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithToIndex(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE id < :to_index ORDER BY id ASC', ['to_index' => 100], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load(new Criteria(new ToIndexCriterion(100))); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithEvents(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE event_name IN (:events) ORDER BY id ASC', [ + 'events' => ['profile.created'], + ], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->events(['profile.created']) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithEventId(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE event_id = :event_id ORDER BY id ASC', ['event_id' => '1'], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load(new Criteria(new EventIdCriterion('1'))); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithUnsupportedCriterion(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->never()) + ->method('executeQuery'); + + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $this->expectException(UnsupportedCriterion::class); + $doctrineDbalStore->load(new Criteria(new stdClass())); + } + + public function testLoadWithArchivedMessageAndCustomHeaders(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new ArrayIterator( + [ + [ + 'id' => 1, + 'stream' => 'profile-1', + 'playhead' => null, + 'event_id' => '1', + 'event_name' => 'profile.created', + 'event_payload' => '{"profileId": "1", "email": "s"}', + 'recorded_on' => '2021-02-17 10:00:00', + 'archived' => '1', + 'custom_headers' => '{"foo": "bar"}', + ], + ], + )); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store ORDER BY id ASC', [], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('deserialize') + ->with(new SerializedEvent('profile.created', '{"profileId": "1", "email": "s"}')) + ->willReturn(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('deserialize') + ->with('{"foo": "bar"}') + ->willReturn([new FooHeader('bar')]); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load(); + $message = $stream->current(); + + self::assertInstanceOf(Message::class, $message); + self::assertFalse($message->hasHeader(PlayheadHeader::class)); + self::assertTrue($message->hasHeader(ArchivedHeader::class)); + self::assertEquals(new FooHeader('bar'), $message->header(FooHeader::class)); + } + + public function testStreams(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('fetchFirstColumn') + ->willReturn(['foo', 'profile-1']); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT DISTINCT stream FROM event_store ORDER BY stream', [], []) + ->willReturn($result); + + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + self::assertSame(['foo', 'profile-1'], $doctrineDbalStore->streams()); + } + + public function testRemove(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('DELETE FROM event_store', [], []) + ->willReturn(1); + + $connection + ->expects($this->never()) + ->method('getDatabasePlatform'); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $doctrineDbalStore->remove(); + } + + public function testRemoveWithCriteria(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('DELETE FROM event_store WHERE stream = :stream_0', ['stream_0' => 'profile-1'], $this->isArray()) + ->willReturn(1); + + $connection + ->expects($this->never()) + ->method('getDatabasePlatform'); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $doctrineDbalStore->remove( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->build(), + ); + } + + public function testArchive(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('UPDATE event_store SET archived = :value', ['value' => true], $this->isArray()) + ->willReturn(1); + + $connection + ->expects($this->never()) + ->method('getDatabasePlatform'); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $doctrineDbalStore->archive(); + } + + public function testArchiveWithCriteria(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('UPDATE event_store SET archived = :value WHERE stream = :stream_0', [ + 'stream_0' => 'profile-1', + 'value' => true, + ], $this->isArray()) + ->willReturn(1); + + $connection + ->expects($this->never()) + ->method('getDatabasePlatform'); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $doctrineDbalStore->archive( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->build(), + ); + } + + public function testSaveWithNoMessages(): void + { + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->never()) + ->method('transactional'); + $mockedConnection + ->expects($this->never()) + ->method('executeStatement'); + + $store = new StreamDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $headersSerializer, + ); + $store->save(); + } + + public function testSaveWithHeaderFallbacks(): void + { + $now = new DateTimeImmutable('2025-01-01 10:00:00'); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $clock = $this->createMock(ClockInterface::class); + $clock + ->expects($this->once()) + ->method('now') + ->willReturn($now); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with( + "INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, recorded_on, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?)", + $this->callback(static function (array $parameters) use ($now): bool { + return $parameters[0] === 'profile-1' + && $parameters[1] === null + && is_string($parameters[2]) + && Uuid::isValid($parameters[2]) + && $parameters[3] === 'profile_created' + && $parameters[4] === '' + && $parameters[5] === $now + && $parameters[6] === false + && $parameters[7] === '[]'; + }), + [ + 5 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 6 => Type::getType(Types::BOOLEAN), + ], + ); + + $store = new StreamDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $headersSerializer, + $clock, + ); + $store->save($message); + } + + public function testSaveWithArchivedHeader(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new ArchivedHeader()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with("INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, recorded_on, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?)", ['profile-1', 1, '1', 'profile_created', '', $recordedOn, true, '[]'], [ + 5 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 6 => Type::getType(Types::BOOLEAN), + ]); + + $store = new StreamDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $headersSerializer, + ); + $store->save($message); + } + + public function testSaveWithCustomTableName(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with("INSERT INTO my_event_store (stream, playhead, event_id, event_name, event_payload, recorded_on, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?)", ['profile-1', 1, '1', 'profile_created', '', $recordedOn, false, '[]'], [ + 5 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 6 => Type::getType(Types::BOOLEAN), + ]); + + $store = new StreamDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $headersSerializer, + config: ['table_name' => 'my_event_store'], + ); + $store->save($message); + } + + public function testSaveWithExactBatchSize(): void + { + $recordedOn = new DateTimeImmutable(); + + $messages = []; + for ($i = 1; $i <= 8191; $i++) { + $messages[] = Message::create(new ProfileEmailChanged(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader($i)) + ->withHeader(new RecordedOnHeader($recordedOn)); + } + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->exactly(8191)) + ->method('serialize') + ->with($messages[0]->event()) + ->willReturn(new SerializedEvent( + 'profile_email_changed', + '', + )); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->exactly(8191)) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement'); + + $store = new StreamDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $headersSerializer, + ); + $store->save(...$messages); + } + + public function testSaveWithKeepIndex(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(42)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with("INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, recorded_on, archived, custom_headers, id) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?, ?)", ['profile-1', 1, '1', 'profile_created', '', $recordedOn, false, '[]', 42], [ + 5 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 6 => Type::getType(Types::BOOLEAN), + ]); + + $store = new StreamDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $headersSerializer, + config: ['keep_index' => true], + ); + $store->save($message); + } + + public function testSaveWithKeepIndexMissingHeader(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->never()) + ->method('executeStatement'); + + $store = new StreamDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $headersSerializer, + config: ['keep_index' => true], + ); + + $this->expectException(MissingDataForStorage::class); + $store->save($message); + } + + public function testSaveWithKeepIndexOnPostgres(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(42)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new PostgreSQLPlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->exactly(2)) + ->method('executeStatement') + ->willReturnCallback(new ReturnCallback([ + [ + [ + "INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, recorded_on, archived, custom_headers, id) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?, ?)", + ['profile-1', 1, '1', 'profile_created', '', $recordedOn, false, '[]', 42], + [ + 5 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 6 => Type::getType(Types::BOOLEAN), + ], + ], + 1, + ], + [ + ["SELECT setval('event_store_id_seq', (SELECT MAX(id) FROM event_store));", [], []], + 1, + ], + ])); + + $store = new StreamDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $headersSerializer, + config: ['keep_index' => true, 'locking' => false], + ); + $store->save($message); + } + + public function testTransactionalWithoutLocking(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->never()) + ->method('getDatabasePlatform'); + $connection + ->expects($this->never()) + ->method('fetchOne'); + $connection + ->expects($this->never()) + ->method('executeStatement'); + + $connection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + config: ['locking' => false], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithCustomLockId(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("42", -1)', 1], + ['SELECT RELEASE_LOCK("42")', 1], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + config: ['lock_id' => 42], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithPostgreSQLCustomLockId(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new PostgreSQLPlatform()); + + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('SELECT pg_advisory_xact_lock(42)'); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + config: ['lock_id' => 42], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalLockingNotImplemented(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + $connection + ->expects($this->never()) + ->method('fetchOne'); + $connection + ->expects($this->never()) + ->method('executeStatement'); + $connection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $this->expectException(LockingNotImplemented::class); + $store->transactional($callback(...)); + } + + public function testSupportSubscription(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new PostgreSQLPlatform()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + self::assertTrue($doctrineDbalStore->supportSubscription()); + } + + public function testSupportSubscriptionNotPostgres(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + self::assertFalse($doctrineDbalStore->supportSubscription()); + } + + public function testWaitNotPostgres(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->never()) + ->method('executeStatement'); + $connection + ->expects($this->never()) + ->method('getNativeConnection'); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + $doctrineDbalStore->wait(100); + } + + public function testConnection(): void + { + $connection = $this->createMock(Connection::class); + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + self::assertSame($connection, $doctrineDbalStore->connection()); + } } diff --git a/tests/Unit/Store/UniqueConstraintViolationTest.php b/tests/Unit/Store/UniqueConstraintViolationTest.php new file mode 100644 index 000000000..6a79c07cc --- /dev/null +++ b/tests/Unit/Store/UniqueConstraintViolationTest.php @@ -0,0 +1,26 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + self::assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/Unit/Store/UnsupportedCriterionTest.php b/tests/Unit/Store/UnsupportedCriterionTest.php new file mode 100644 index 000000000..97b17500a --- /dev/null +++ b/tests/Unit/Store/UnsupportedCriterionTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Store/WrongQueryResultTest.php b/tests/Unit/Store/WrongQueryResultTest.php new file mode 100644 index 000000000..ddf164b28 --- /dev/null +++ b/tests/Unit/Store/WrongQueryResultTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/Cleanup/CleanupFailedTest.php b/tests/Unit/Subscription/Cleanup/CleanupFailedTest.php new file mode 100644 index 000000000..3444b5906 --- /dev/null +++ b/tests/Unit/Subscription/Cleanup/CleanupFailedTest.php @@ -0,0 +1,31 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + self::assertSame($task, $exception->task); + self::assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/Unit/Subscription/Cleanup/CleanupTaskNotSupportedTest.php b/tests/Unit/Subscription/Cleanup/CleanupTaskNotSupportedTest.php new file mode 100644 index 000000000..1c49ab1dd --- /dev/null +++ b/tests/Unit/Subscription/Cleanup/CleanupTaskNotSupportedTest.php @@ -0,0 +1,28 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/Cleanup/Dbal/ConnectionNameNotSupportedTest.php b/tests/Unit/Subscription/Cleanup/Dbal/ConnectionNameNotSupportedTest.php new file mode 100644 index 000000000..9688e2605 --- /dev/null +++ b/tests/Unit/Subscription/Cleanup/Dbal/ConnectionNameNotSupportedTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/Cleanup/Dbal/DropIndexTaskTest.php b/tests/Unit/Subscription/Cleanup/Dbal/DropIndexTaskTest.php new file mode 100644 index 000000000..ffddd8071 --- /dev/null +++ b/tests/Unit/Subscription/Cleanup/Dbal/DropIndexTaskTest.php @@ -0,0 +1,30 @@ +index); + self::assertSame('projection_table', $object->table); + self::assertSame('foo', $object->connectionName); + } + + public function testInstantiateWithDefaults(): void + { + $object = new DropIndexTask('idx_foo', 'projection_table'); + + self::assertSame('idx_foo', $object->index); + self::assertNull($object->connectionName); + } +} diff --git a/tests/Unit/Subscription/Cleanup/Dbal/DropTableTaskTest.php b/tests/Unit/Subscription/Cleanup/Dbal/DropTableTaskTest.php new file mode 100644 index 000000000..1ec270adf --- /dev/null +++ b/tests/Unit/Subscription/Cleanup/Dbal/DropTableTaskTest.php @@ -0,0 +1,29 @@ +table); + self::assertSame('foo', $object->connectionName); + } + + public function testInstantiateWithDefaults(): void + { + $object = new DropTableTask('projection_table'); + + self::assertSame('projection_table', $object->table); + self::assertNull($object->connectionName); + } +} diff --git a/tests/Unit/Subscription/Cleanup/Dbal/UnexpectedConnectionTypeTest.php b/tests/Unit/Subscription/Cleanup/Dbal/UnexpectedConnectionTypeTest.php new file mode 100644 index 000000000..df29688e4 --- /dev/null +++ b/tests/Unit/Subscription/Cleanup/Dbal/UnexpectedConnectionTypeTest.php @@ -0,0 +1,39 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testCreateWithNullConnectionName(): void + { + $exception = new UnexpectedConnectionType(null, Connection::class, PDO::class); + + self::assertSame( + sprintf('Expected connection "default (null)" to be of type "%s", got "%s"', Connection::class, PDO::class), + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/Cleanup/DefaultCleanerTest.php b/tests/Unit/Subscription/Cleanup/DefaultCleanerTest.php index 85700364d..58ff3afaf 100644 --- a/tests/Unit/Subscription/Cleanup/DefaultCleanerTest.php +++ b/tests/Unit/Subscription/Cleanup/DefaultCleanerTest.php @@ -93,4 +93,43 @@ public function testNoHandler(): void new Subscription('test', cleanupTasks: [new DropTableTask('test')]), ); } + + public function testSkipsUnsupportedHandler(): void + { + $unsupportedHandler = new class implements CleanupTaskHandler { + public bool $called = false; + + public function __invoke(object $task): void + { + $this->called = true; + } + + public function supports(object $task): bool + { + return false; + } + }; + + $supportedHandler = new class implements CleanupTaskHandler { + public bool $called = false; + + public function __invoke(object $task): void + { + $this->called = true; + } + + public function supports(object $task): bool + { + return true; + } + }; + + $cleaner = new DefaultCleaner([$unsupportedHandler, $supportedHandler]); + $cleaner->cleanup( + new Subscription('test', cleanupTasks: [new DropTableTask('test')]), + ); + + self::assertFalse($unsupportedHandler->called); + self::assertTrue($supportedHandler->called); + } } diff --git a/tests/Unit/Subscription/Cleanup/NoHandlerForCleanupTaskTest.php b/tests/Unit/Subscription/Cleanup/NoHandlerForCleanupTaskTest.php new file mode 100644 index 000000000..4f2578c2e --- /dev/null +++ b/tests/Unit/Subscription/Cleanup/NoHandlerForCleanupTaskTest.php @@ -0,0 +1,28 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + self::assertSame($task, $exception->task); + } +} diff --git a/tests/Unit/Subscription/Engine/AlreadyProcessingTest.php b/tests/Unit/Subscription/Engine/AlreadyProcessingTest.php new file mode 100644 index 000000000..45c30387f --- /dev/null +++ b/tests/Unit/Subscription/Engine/AlreadyProcessingTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php index cd6bc2133..56d14b43a 100644 --- a/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php +++ b/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php @@ -8,6 +8,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\Error; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; +use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -86,4 +87,18 @@ public function testSubscriptions(): void self::assertEquals($expectedSubscriptions, $subscriptions); } + + public function testPassthroughUnexpectedResult(): void + { + $parent = $this->createMock(SubscriptionEngine::class); + + $engine = new CatchUpSubscriptionEngine($parent); + + $expectedResult = new Result(); + $command = new Run(); + + $parent->expects($this->once())->method('execute')->with($command)->willReturn($expectedResult); + + self::assertSame($expectedResult, $engine->execute($command)); + } } diff --git a/tests/Unit/Subscription/Engine/CleanerNotConfiguredTest.php b/tests/Unit/Subscription/Engine/CleanerNotConfiguredTest.php new file mode 100644 index 000000000..de5dc9ea8 --- /dev/null +++ b/tests/Unit/Subscription/Engine/CleanerNotConfiguredTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/Engine/CleanupRunnerTest.php b/tests/Unit/Subscription/Engine/CleanupRunnerTest.php new file mode 100644 index 000000000..e1e0789e7 --- /dev/null +++ b/tests/Unit/Subscription/Engine/CleanupRunnerTest.php @@ -0,0 +1,112 @@ +createMock(SubscriptionStore::class); + $runner = new CleanupRunner(new SubscriptionManager($subscriptionStore)); + + $this->expectException(CleanerNotConfigured::class); + + $runner->cleanup(new Subscription('foo')); + } + + public function testCleanupSuccessful(): void + { + $subscription = new Subscription('foo'); + + $subscriptionStore = $this->createMock(SubscriptionStore::class); + $subscriptionStore + ->expects($this->once()) + ->method('remove') + ->with($subscription); + + $cleaner = $this->createMock(Cleaner::class); + $cleaner + ->expects($this->once()) + ->method('cleanup') + ->with($subscription); + + $subscriptionManager = new SubscriptionManager($subscriptionStore); + $runner = new CleanupRunner($subscriptionManager, $cleaner); + + self::assertNull($runner->cleanup($subscription)); + + $subscriptionManager->flush(); + } + + public function testCleanupFailed(): void + { + $subscription = new Subscription('foo'); + + $subscriptionStore = $this->createMock(SubscriptionStore::class); + $subscriptionStore + ->expects($this->never()) + ->method('remove'); + + $exception = new RuntimeException('cleanup failed'); + + $cleaner = $this->createMock(Cleaner::class); + $cleaner + ->expects($this->once()) + ->method('cleanup') + ->with($subscription) + ->willThrowException($exception); + + $subscriptionManager = new SubscriptionManager($subscriptionStore); + $runner = new CleanupRunner($subscriptionManager, $cleaner); + + $error = $runner->cleanup($subscription); + + self::assertInstanceOf(Error::class, $error); + self::assertSame('foo', $error->subscriptionId); + self::assertSame('cleanup failed', $error->message); + self::assertSame($exception, $error->throwable); + + $subscriptionManager->flush(); + } + + public function testCleanupFailedWithForce(): void + { + $subscription = new Subscription('foo'); + + $subscriptionStore = $this->createMock(SubscriptionStore::class); + $subscriptionStore + ->expects($this->once()) + ->method('remove') + ->with($subscription); + + $cleaner = $this->createMock(Cleaner::class); + $cleaner + ->expects($this->once()) + ->method('cleanup') + ->with($subscription) + ->willThrowException(new RuntimeException('cleanup failed')); + + $subscriptionManager = new SubscriptionManager($subscriptionStore); + $runner = new CleanupRunner($subscriptionManager, $cleaner); + + $error = $runner->cleanup($subscription, true); + + self::assertInstanceOf(Error::class, $error); + + $subscriptionManager->flush(); + } +} diff --git a/tests/Unit/Subscription/Engine/Command/BootTest.php b/tests/Unit/Subscription/Engine/Command/BootTest.php new file mode 100644 index 000000000..dd08b2a66 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Command/BootTest.php @@ -0,0 +1,31 @@ +ids); + self::assertSame(['bar'], $object->groups); + self::assertSame(100, $object->limit); + } + + public function testInstantiateWithDefaults(): void + { + $object = new Boot(); + + self::assertNull($object->ids); + self::assertNull($object->groups); + self::assertNull($object->limit); + } +} diff --git a/tests/Unit/Subscription/Engine/Command/RunTest.php b/tests/Unit/Subscription/Engine/Command/RunTest.php new file mode 100644 index 000000000..dbcbc903c --- /dev/null +++ b/tests/Unit/Subscription/Engine/Command/RunTest.php @@ -0,0 +1,31 @@ +ids); + self::assertSame(['bar'], $object->groups); + self::assertSame(100, $object->limit); + } + + public function testInstantiateWithDefaults(): void + { + $object = new Run(); + + self::assertNull($object->ids); + self::assertNull($object->groups); + self::assertNull($object->limit); + } +} diff --git a/tests/Unit/Subscription/Engine/Command/SetupTest.php b/tests/Unit/Subscription/Engine/Command/SetupTest.php new file mode 100644 index 000000000..78b4c9f06 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Command/SetupTest.php @@ -0,0 +1,31 @@ +ids); + self::assertSame(['bar'], $object->groups); + self::assertTrue($object->skipBooting); + } + + public function testInstantiateWithDefaults(): void + { + $object = new Setup(); + + self::assertNull($object->ids); + self::assertNull($object->groups); + self::assertFalse($object->skipBooting); + } +} diff --git a/tests/Unit/Subscription/Engine/ErrorTest.php b/tests/Unit/Subscription/Engine/ErrorTest.php new file mode 100644 index 000000000..8b5918cb6 --- /dev/null +++ b/tests/Unit/Subscription/Engine/ErrorTest.php @@ -0,0 +1,23 @@ +subscriptionId); + self::assertSame('something went wrong', $object->message); + self::assertSame($throwable, $object->throwable); + } +} diff --git a/tests/Unit/Subscription/Engine/Event/OnCommandTest.php b/tests/Unit/Subscription/Engine/Event/OnCommandTest.php new file mode 100644 index 000000000..5a78252a7 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Event/OnCommandTest.php @@ -0,0 +1,23 @@ +command); + } +} diff --git a/tests/Unit/Subscription/Engine/Event/OnHandleMessageErrorTest.php b/tests/Unit/Subscription/Engine/Event/OnHandleMessageErrorTest.php new file mode 100644 index 000000000..3178a6e76 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Event/OnHandleMessageErrorTest.php @@ -0,0 +1,34 @@ +subscription); + self::assertSame($throwable, $event->throwable); + self::assertSame($message, $event->message); + self::assertSame(42, $event->index); + self::assertFalse($event->transitionToFailed); + } +} diff --git a/tests/Unit/Subscription/Engine/Event/OnHandleMessageSuccessTest.php b/tests/Unit/Subscription/Engine/Event/OnHandleMessageSuccessTest.php new file mode 100644 index 000000000..5d9a737bf --- /dev/null +++ b/tests/Unit/Subscription/Engine/Event/OnHandleMessageSuccessTest.php @@ -0,0 +1,31 @@ +subscription); + self::assertSame($message, $event->message); + self::assertSame(42, $event->index); + self::assertTrue($event->shouldChangePosition); + } +} diff --git a/tests/Unit/Subscription/Engine/Event/OnHandleMessageTest.php b/tests/Unit/Subscription/Engine/Event/OnHandleMessageTest.php new file mode 100644 index 000000000..9e7467ae9 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Event/OnHandleMessageTest.php @@ -0,0 +1,29 @@ +subscription); + self::assertSame($message, $event->message); + } +} diff --git a/tests/Unit/Subscription/Engine/Event/OnResultTest.php b/tests/Unit/Subscription/Engine/Event/OnResultTest.php new file mode 100644 index 000000000..64ef6c6d7 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Event/OnResultTest.php @@ -0,0 +1,26 @@ +command); + self::assertSame($result, $event->result); + } +} diff --git a/tests/Unit/Subscription/Engine/Event/OnSubscriptionProcessedTest.php b/tests/Unit/Subscription/Engine/Event/OnSubscriptionProcessedTest.php new file mode 100644 index 000000000..dae69a0a2 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Event/OnSubscriptionProcessedTest.php @@ -0,0 +1,25 @@ +subscription); + self::assertSame(42, $event->lastIndex); + self::assertSame([], $event->errors); + } +} diff --git a/tests/Unit/Subscription/Engine/Event/OnSubscriptionRemovedTest.php b/tests/Unit/Subscription/Engine/Event/OnSubscriptionRemovedTest.php new file mode 100644 index 000000000..84545f111 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Event/OnSubscriptionRemovedTest.php @@ -0,0 +1,23 @@ +subscription); + } +} diff --git a/tests/Unit/Subscription/Engine/Event/OnSubscriptionsTest.php b/tests/Unit/Subscription/Engine/Event/OnSubscriptionsTest.php new file mode 100644 index 000000000..8ea2e65d7 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Event/OnSubscriptionsTest.php @@ -0,0 +1,23 @@ +criteria); + } +} diff --git a/tests/Unit/Subscription/Engine/EventFilteredStoreMessageLoaderTest.php b/tests/Unit/Subscription/Engine/EventFilteredStoreMessageLoaderTest.php new file mode 100644 index 000000000..d7a684758 --- /dev/null +++ b/tests/Unit/Subscription/Engine/EventFilteredStoreMessageLoaderTest.php @@ -0,0 +1,150 @@ +createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->with(new Criteria( + new FromIndexCriterion(5), + new EventsCriterion(['profile_created']), + )) + ->willReturn($stream); + + $accessor = new MetadataSubscriberAccessor( + new class { + }, + new SubscriberMetadata('foo', subscribeMethods: [ + ProfileCreated::class => new SubscribeMethodMetadata('onProfileCreated'), + ]), + ); + + $subscriberRepository = $this->createMock(SubscriberAccessorRepository::class); + $subscriberRepository + ->expects($this->once()) + ->method('get') + ->with('foo') + ->willReturn($accessor); + + $loader = new EventFilteredStoreMessageLoader( + $store, + new AttributeEventMetadataFactory(), + $subscriberRepository, + ); + + self::assertSame($stream, $loader->load(5, [new Subscription('foo')])); + } + + public function testLoadWithoutFilterBecauseSubscribeAll(): void + { + $stream = new Stream(); + + $store = $this->createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->with(new Criteria()) + ->willReturn($stream); + + $accessor = new MetadataSubscriberAccessor( + new class { + }, + new SubscriberMetadata('foo', subscribeMethods: [ + '*' => new SubscribeMethodMetadata('onAll'), + ]), + ); + + $subscriberRepository = $this->createMock(SubscriberAccessorRepository::class); + $subscriberRepository + ->expects($this->once()) + ->method('get') + ->with('foo') + ->willReturn($accessor); + + $loader = new EventFilteredStoreMessageLoader( + $store, + new AttributeEventMetadataFactory(), + $subscriberRepository, + ); + + self::assertSame($stream, $loader->load(null, [new Subscription('foo')])); + } + + public function testLoadWithoutFilterBecauseSubscriberNotFound(): void + { + $stream = new Stream(); + + $store = $this->createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->with(new Criteria()) + ->willReturn($stream); + + $subscriberRepository = $this->createMock(SubscriberAccessorRepository::class); + $subscriberRepository + ->expects($this->once()) + ->method('get') + ->with('foo') + ->willReturn(null); + + $loader = new EventFilteredStoreMessageLoader( + $store, + new AttributeEventMetadataFactory(), + $subscriberRepository, + ); + + self::assertSame($stream, $loader->load(null, [new Subscription('foo')])); + } + + public function testLastIndex(): void + { + $message = Message::create(new ProfileVisited(ProfileId::fromString('1'))); + + $store = $this->createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->with(null, 1, null, true) + ->willReturn(new Stream([5 => $message])); + + $subscriberRepository = $this->createMock(SubscriberAccessorRepository::class); + + $loader = new EventFilteredStoreMessageLoader( + $store, + new AttributeEventMetadataFactory(), + $subscriberRepository, + ); + + self::assertSame(5, $loader->lastIndex()); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php index 3b5ebb3f0..1970c268b 100644 --- a/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php @@ -121,4 +121,22 @@ class { new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), ); } + + public function testReactivateWithMissingSubscriber(): void + { + $subscriptionId = 'test'; + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + status: Status::Paused, + ), + ]); + + $handler = $this->createHandler($store); + $result = $handler(new ReactivateCommand()); + + self::assertEquals([], $result->errors); + self::assertSame([], $store->updatedSubscriptions); + } } diff --git a/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php index 038ca2dee..4a922c548 100644 --- a/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php @@ -126,4 +126,17 @@ public function cleanup(): iterable new Subscription('test', 'new-group', RunMode::FromNow, Status::Active, cleanupTasks: [new DropTableTask('test')]), ); } + + public function testRefreshWithMissingSubscriber(): void + { + $subscriptionId = 'test'; + + $store = new DummySubscriptionStore([new Subscription($subscriptionId)]); + + $handler = $this->createHandler($store); + $result = $handler(new RefreshCommand()); + + self::assertEquals([], $result->errors); + self::assertSame([], $store->updatedSubscriptions); + } } diff --git a/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php index 8a59e3cf1..aa4dbf71c 100644 --- a/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php @@ -351,4 +351,23 @@ class { new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromNow, Status::Active, 0), ); } + + public function testSetupWithMissingSubscriber(): void + { + $subscriptionId = 'test'; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(0); + + $store = new DummySubscriptionStore([new Subscription($subscriptionId)]); + $handler = $this->createHandler($messageLoader, $store); + + $result = $handler(new SetupCommand()); + + self::assertCount(1, $result->errors); + self::assertSame($subscriptionId, $result->errors[0]->subscriptionId); + self::assertSame('Subscriber with the subscription id "test" not found.', $result->errors[0]->message); + self::assertCount(1, $store->updatedSubscriptions); + self::assertTrue($store->updatedSubscriptions[0]->isError()); + } } diff --git a/tests/Unit/Subscription/Engine/ProcessedResultTest.php b/tests/Unit/Subscription/Engine/ProcessedResultTest.php new file mode 100644 index 000000000..25e5f9e24 --- /dev/null +++ b/tests/Unit/Subscription/Engine/ProcessedResultTest.php @@ -0,0 +1,52 @@ +processedMessages); + self::assertTrue($object->finished); + self::assertSame([$error], $object->errors); + } + + public function testEmpty(): void + { + $result = ProcessedResult::empty(); + + self::assertSame(0, $result->processedMessages); + self::assertTrue($result->finished); + self::assertSame([], $result->errors); + } + + public function testMergeEmpty(): void + { + self::assertEquals(ProcessedResult::empty(), ProcessedResult::merge([])); + } + + public function testMerge(): void + { + $error = new Error('foo', 'bar', new RuntimeException('error')); + + $result = ProcessedResult::merge([ + new ProcessedResult(2, true), + new ProcessedResult(3, false, [$error]), + ]); + + self::assertSame(5, $result->processedMessages); + self::assertFalse($result->finished); + self::assertSame([$error], $result->errors); + } +} diff --git a/tests/Unit/Subscription/Engine/ResultTest.php b/tests/Unit/Subscription/Engine/ResultTest.php new file mode 100644 index 000000000..46aa47de3 --- /dev/null +++ b/tests/Unit/Subscription/Engine/ResultTest.php @@ -0,0 +1,37 @@ +errors); + } + + public function testEmpty(): void + { + self::assertSame([], Result::empty()->errors); + } + + public function testMerge(): void + { + $error1 = new Error('foo', 'bar', new RuntimeException('error')); + $error2 = new Error('baz', 'qux', new RuntimeException('error')); + + $result = Result::merge([new Result([$error1]), new Result([$error2])]); + + self::assertSame([$error1, $error2], $result->errors); + } +} diff --git a/tests/Unit/Subscription/Engine/StoreMessageLoaderTest.php b/tests/Unit/Subscription/Engine/StoreMessageLoaderTest.php new file mode 100644 index 000000000..d5b6797f1 --- /dev/null +++ b/tests/Unit/Subscription/Engine/StoreMessageLoaderTest.php @@ -0,0 +1,82 @@ +createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->with(new Criteria()) + ->willReturn($stream); + + $loader = new StoreMessageLoader($store); + + self::assertSame($stream, $loader->load(null, [])); + } + + public function testLoadWithStartIndex(): void + { + $stream = new Stream(); + + $store = $this->createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->with(new Criteria(new FromIndexCriterion(5))) + ->willReturn($stream); + + $loader = new StoreMessageLoader($store); + + self::assertSame($stream, $loader->load(5, [])); + } + + public function testLastIndex(): void + { + $message = Message::create(new ProfileVisited(ProfileId::fromString('1'))); + + $store = $this->createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->with(null, 1, null, true) + ->willReturn(new Stream([5 => $message])); + + $loader = new StoreMessageLoader($store); + + self::assertSame(5, $loader->lastIndex()); + } + + public function testLastIndexEmptyStream(): void + { + $store = $this->createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->with(null, 1, null, true) + ->willReturn(new Stream()); + + $loader = new StoreMessageLoader($store); + + self::assertSame(0, $loader->lastIndex()); + } +} diff --git a/tests/Unit/Subscription/Engine/SubscriberNotFoundTest.php b/tests/Unit/Subscription/Engine/SubscriberNotFoundTest.php new file mode 100644 index 000000000..3810ecd2b --- /dev/null +++ b/tests/Unit/Subscription/Engine/SubscriberNotFoundTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/Engine/SubscriptionCollectionTest.php b/tests/Unit/Subscription/Engine/SubscriptionCollectionTest.php index fb3e2e715..7f5a6d83f 100644 --- a/tests/Unit/Subscription/Engine/SubscriptionCollectionTest.php +++ b/tests/Unit/Subscription/Engine/SubscriptionCollectionTest.php @@ -55,4 +55,14 @@ public function testNullPositionStaysNullAsLowestPosition(): void self::assertSame(null, $collection->lowestPosition()); } + + public function testToArray(): void + { + $subscription1 = new Subscription('foo', position: 5); + $subscription2 = new Subscription('bar', position: 10); + + $collection = new SubscriptionCollection([$subscription1, $subscription2]); + + self::assertSame([$subscription1, $subscription2], $collection->toArray()); + } } diff --git a/tests/Unit/Subscription/Engine/SubscriptionEngineCriteriaTest.php b/tests/Unit/Subscription/Engine/SubscriptionEngineCriteriaTest.php new file mode 100644 index 000000000..277b67733 --- /dev/null +++ b/tests/Unit/Subscription/Engine/SubscriptionEngineCriteriaTest.php @@ -0,0 +1,29 @@ +ids); + self::assertSame(['bar'], $criteria->groups); + } + + public function testInstantiateWithDefaults(): void + { + $criteria = new SubscriptionEngineCriteria(); + + self::assertNull($criteria->ids); + self::assertNull($criteria->groups); + } +} diff --git a/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php b/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php new file mode 100644 index 000000000..d8da7ef03 --- /dev/null +++ b/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php @@ -0,0 +1,199 @@ +runner( + $this->createMock(MessageLoader::class), + $this->createMock(SubscriberAccessorRepository::class), + ); + + self::assertEquals(ProcessedResult::empty(), $runner->process([], null)); + } + + public function testProcessSkipsUnknownSubscriber(): void + { + $loader = $this->createMock(MessageLoader::class); + $loader + ->expects($this->never()) + ->method('load'); + + $subscriberRepository = $this->createMock(SubscriberAccessorRepository::class); + $subscriberRepository + ->expects($this->atLeastOnce()) + ->method('get') + ->with('foo') + ->willReturn(null); + + $runner = $this->runner($loader, $subscriberRepository); + + self::assertEquals( + ProcessedResult::empty(), + $runner->process([new Subscription('foo')], null), + ); + } + + public function testProcessMessages(): void + { + $subscription = new Subscription('foo', runMode: RunMode::FromBeginning, status: Status::Active); + + $message = Message::create(new ProfileVisited(ProfileId::fromString('1'))); + + $loader = $this->createMock(MessageLoader::class); + $loader + ->expects($this->once()) + ->method('load') + ->with(null, [$subscription]) + ->willReturn(new Stream([1 => $message, 2 => $message])); + + $subscriberRepository = $this->subscriberRepository(); + + $runner = $this->runner($loader, $subscriberRepository); + + $result = $runner->process([$subscription], null); + + self::assertSame(2, $result->processedMessages); + self::assertTrue($result->finished); + self::assertSame([], $result->errors); + self::assertSame(2, $subscription->position()); + self::assertSame(Status::Active, $subscription->status()); + } + + public function testProcessWithLimit(): void + { + $subscription = new Subscription('foo', runMode: RunMode::FromBeginning, status: Status::Active); + + $message = Message::create(new ProfileVisited(ProfileId::fromString('1'))); + + $loader = $this->createMock(MessageLoader::class); + $loader + ->expects($this->once()) + ->method('load') + ->with(null, [$subscription]) + ->willReturn(new Stream([1 => $message, 2 => $message])); + + $runner = $this->runner($loader, $this->subscriberRepository()); + + $result = $runner->process([$subscription], 1); + + self::assertSame(1, $result->processedMessages); + self::assertFalse($result->finished); + self::assertSame(1, $subscription->position()); + } + + public function testProcessFinishesRunOnceSubscription(): void + { + $subscription = new Subscription('foo', runMode: RunMode::Once, status: Status::Active); + + $message = Message::create(new ProfileVisited(ProfileId::fromString('1'))); + + $loader = $this->createMock(MessageLoader::class); + $loader + ->expects($this->once()) + ->method('load') + ->willReturn(new Stream([1 => $message])); + + $runner = $this->runner($loader, $this->subscriberRepository()); + + $result = $runner->process([$subscription], null); + + self::assertTrue($result->finished); + self::assertSame(Status::Finished, $subscription->status()); + } + + public function testProcessBootFinishesRunOnceSubscription(): void + { + $subscription = new Subscription('foo', runMode: RunMode::Once, status: Status::Booting); + + $message = Message::create(new ProfileVisited(ProfileId::fromString('1'))); + + $loader = $this->createMock(MessageLoader::class); + $loader + ->expects($this->once()) + ->method('load') + ->willReturn(new Stream([1 => $message])); + + $runner = $this->runner($loader, $this->subscriberRepository()); + + $runner->process([$subscription], null, true); + + self::assertSame(Status::Finished, $subscription->status()); + } + + public function testProcessBootActivatesSubscription(): void + { + $subscription = new Subscription('foo', runMode: RunMode::FromBeginning, status: Status::Booting); + + $message = Message::create(new ProfileVisited(ProfileId::fromString('1'))); + + $loader = $this->createMock(MessageLoader::class); + $loader + ->expects($this->once()) + ->method('load') + ->willReturn(new Stream([1 => $message])); + + $runner = $this->runner($loader, $this->subscriberRepository()); + + $runner->process([$subscription], null, true); + + self::assertSame(Status::Active, $subscription->status()); + } + + private function runner( + MessageLoader $loader, + SubscriberAccessorRepository $subscriberRepository, + ): SubscriptionRunner { + $eventDispatcher = new EventDispatcher(); + + return new SubscriptionRunner( + $loader, + new SubscriptionManager($this->createMock(SubscriptionStore::class)), + $subscriberRepository, + new MessageProcessor($subscriberRepository, $eventDispatcher), + $eventDispatcher, + ); + } + + private function subscriberRepository(): SubscriberAccessorRepository + { + $accessor = new MetadataSubscriberAccessor( + new class { + }, + new SubscriberMetadata('foo'), + ); + + $subscriberRepository = $this->createMock(SubscriberAccessorRepository::class); + $subscriberRepository + ->method('get') + ->with('foo') + ->willReturn($accessor); + + return $subscriberRepository; + } +} diff --git a/tests/Unit/Subscription/Lookup/LookupTest.php b/tests/Unit/Subscription/Lookup/LookupTest.php index 9f8dacdc0..afe1f1ed1 100644 --- a/tests/Unit/Subscription/Lookup/LookupTest.php +++ b/tests/Unit/Subscription/Lookup/LookupTest.php @@ -16,6 +16,7 @@ use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Lookup\Lookup; +use Patchlevel\EventSourcing\Subscription\Lookup\MessageNotFound; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -280,4 +281,98 @@ public function testFetchLast(): void self::assertSame($message2, $result); } + + public function testResetStream(): void + { + $expectedResult = new Stream([]); + $expectedCriteria = new Criteria(new ToIndexCriterion(1)); + + $store = $this->createMock(Store::class); + $store->expects($this->once())->method('load')->with($expectedCriteria, null, null, false) + ->willReturn($expectedResult); + + $event = new class () { + }; + + $message = (new Message($event)) + ->withHeader(new IndexHeader(1)); + + $lookup = new Lookup( + $store, + $message, + ); + + $result = $lookup->stream('foo')->stream(null)->fetchAll(); + + self::assertSame($expectedResult, $result); + } + + public function testForward(): void + { + $expectedResult = new Stream([]); + $expectedCriteria = new Criteria(new ToIndexCriterion(1)); + + $store = $this->createMock(Store::class); + $store->expects($this->once())->method('load')->with($expectedCriteria, null, null, false) + ->willReturn($expectedResult); + + $event = new class () { + }; + + $message = (new Message($event)) + ->withHeader(new IndexHeader(1)); + + $lookup = new Lookup( + $store, + $message, + ); + + $result = $lookup->backwards()->forward()->fetchAll(); + + self::assertSame($expectedResult, $result); + } + + public function testFetchFirstNotFound(): void + { + $store = $this->createMock(Store::class); + $store->expects($this->once())->method('load')->with(new Criteria(new ToIndexCriterion(1)), 1, null, false) + ->willReturn(new Stream([])); + + $event = new class () { + }; + + $message = (new Message($event)) + ->withHeader(new IndexHeader(1)); + + $lookup = new Lookup( + $store, + $message, + ); + + $this->expectException(MessageNotFound::class); + + $lookup->fetchFirst(); + } + + public function testFetchLastNotFound(): void + { + $store = $this->createMock(Store::class); + $store->expects($this->once())->method('load')->with(new Criteria(new ToIndexCriterion(1)), 1, null, true) + ->willReturn(new Stream([])); + + $event = new class () { + }; + + $message = (new Message($event)) + ->withHeader(new IndexHeader(1)); + + $lookup = new Lookup( + $store, + $message, + ); + + $this->expectException(MessageNotFound::class); + + $lookup->fetchLast(); + } } diff --git a/tests/Unit/Subscription/Lookup/MessageNotFoundTest.php b/tests/Unit/Subscription/Lookup/MessageNotFoundTest.php new file mode 100644 index 000000000..49179cf9a --- /dev/null +++ b/tests/Unit/Subscription/Lookup/MessageNotFoundTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/NoErrorToRetryTest.php b/tests/Unit/Subscription/NoErrorToRetryTest.php new file mode 100644 index 000000000..903601961 --- /dev/null +++ b/tests/Unit/Subscription/NoErrorToRetryTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/RetryStrategy/ClockBasedRetryStrategyTest.php b/tests/Unit/Subscription/RetryStrategy/ClockBasedRetryStrategyTest.php index 8f6522011..bb57d7022 100644 --- a/tests/Unit/Subscription/RetryStrategy/ClockBasedRetryStrategyTest.php +++ b/tests/Unit/Subscription/RetryStrategy/ClockBasedRetryStrategyTest.php @@ -98,4 +98,14 @@ public static function canRetryProvider(): Generator yield [0, 0, false]; yield [1, 0, true]; } + + public function testShouldNotRetryWithoutLastSavedAt(): void + { + $clock = new FrozenClock(new DateTimeImmutable('2024-01-01 10:00:00')); + $strategy = new ClockBasedRetryStrategy($clock); + + $subscription = new Subscription('test', retryAttempt: 1); + + self::assertFalse($strategy->shouldRetry($subscription)); + } } diff --git a/tests/Unit/Subscription/RetryStrategy/NoRetryStrategyTest.php b/tests/Unit/Subscription/RetryStrategy/NoRetryStrategyTest.php index 4bd435bcb..116ef557a 100644 --- a/tests/Unit/Subscription/RetryStrategy/NoRetryStrategyTest.php +++ b/tests/Unit/Subscription/RetryStrategy/NoRetryStrategyTest.php @@ -20,4 +20,13 @@ public function testNull(): void new Subscription('test'), )); } + + public function testCanRetry(): void + { + $strategy = new NoRetryStrategy(); + + self::assertFalse($strategy->canRetry( + new Subscription('test'), + )); + } } diff --git a/tests/Unit/Subscription/RetryStrategy/RetryStrategyNotFoundTest.php b/tests/Unit/Subscription/RetryStrategy/RetryStrategyNotFoundTest.php new file mode 100644 index 000000000..b174ea204 --- /dev/null +++ b/tests/Unit/Subscription/RetryStrategy/RetryStrategyNotFoundTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/RetryStrategy/RetryStrategyRepositoryTest.php b/tests/Unit/Subscription/RetryStrategy/RetryStrategyRepositoryTest.php index 1cbcf9f07..49993e1d8 100644 --- a/tests/Unit/Subscription/RetryStrategy/RetryStrategyRepositoryTest.php +++ b/tests/Unit/Subscription/RetryStrategy/RetryStrategyRepositoryTest.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\RetryStrategy; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\NoRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyNotFound; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; @@ -50,4 +51,13 @@ public function testGetRetryStrategy(): void self::assertSame($strategy, $repository->get('test')); } + + public function testWithDefault(): void + { + $strategy = new NoRetryStrategy(); + + $repository = RetryStrategyRepository::withDefault($strategy); + + self::assertSame($strategy, $repository->getDefaultRetryStrategy()); + } } diff --git a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php new file mode 100644 index 000000000..cb6c1b4c5 --- /dev/null +++ b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php @@ -0,0 +1,595 @@ +createMock(Connection::class); + $connection + ->expects($this->atLeastOnce()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $connection + ->expects($this->once()) + ->method('fetchAssociative') + ->with('SELECT * FROM subscriptions WHERE id = :id', ['id' => 'foo']) + ->willReturn([ + 'id' => 'foo', + 'group_name' => 'default', + 'run_mode' => 'from_beginning', + 'position' => 42, + 'status' => 'active', + 'error_message' => null, + 'error_previous_status' => null, + 'error_context' => null, + 'retry_attempt' => 0, + 'last_saved_at' => '2024-01-01 10:00:00', + 'cleanup_tasks' => null, + ]); + + $store = new DoctrineSubscriptionStore($connection); + + self::assertEquals( + new Subscription( + 'foo', + 'default', + RunMode::FromBeginning, + Status::Active, + 42, + null, + 0, + new DateTimeImmutable('2024-01-01 10:00:00'), + ), + $store->get('foo'), + ); + } + + public function testGetWithErrorAndCleanupTasks(): void + { + $task = new DropTableTask('projection_table'); + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->atLeastOnce()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $connection + ->expects($this->once()) + ->method('fetchAssociative') + ->with('SELECT * FROM subscriptions WHERE id = :id', ['id' => 'foo']) + ->willReturn([ + 'id' => 'foo', + 'group_name' => 'default', + 'run_mode' => 'from_beginning', + 'position' => null, + 'status' => 'error', + 'error_message' => 'something went wrong', + 'error_previous_status' => 'active', + 'error_context' => '[]', + 'retry_attempt' => 2, + 'last_saved_at' => '2024-01-01 10:00:00', + 'cleanup_tasks' => serialize([$task]), + ]); + + $store = new DoctrineSubscriptionStore($connection); + + self::assertEquals( + new Subscription( + 'foo', + 'default', + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError('something went wrong', Status::Active, []), + 2, + new DateTimeImmutable('2024-01-01 10:00:00'), + [$task], + ), + $store->get('foo'), + ); + } + + public function testGetNotFound(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->atLeastOnce()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $connection + ->expects($this->once()) + ->method('fetchAssociative') + ->with('SELECT * FROM subscriptions WHERE id = :id', ['id' => 'foo']) + ->willReturn(false); + + $store = new DoctrineSubscriptionStore($connection); + + $this->expectException(SubscriptionNotFound::class); + + $store->get('foo'); + } + + public function testFind(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->atLeastOnce()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('fetchAllAssociative') + ->willReturn([ + [ + 'id' => 'foo', + 'group_name' => 'default', + 'run_mode' => 'from_beginning', + 'position' => null, + 'status' => 'new', + 'error_message' => null, + 'error_previous_status' => null, + 'error_context' => null, + 'retry_attempt' => 0, + 'last_saved_at' => '2024-01-01 10:00:00', + 'cleanup_tasks' => null, + ], + ]); + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM subscriptions ORDER BY id', [], $this->isArray()) + ->willReturn($result); + + $store = new DoctrineSubscriptionStore($connection); + + $subscriptions = $store->find(); + + self::assertCount(1, $subscriptions); + self::assertSame('foo', $subscriptions[0]->id()); + } + + public function testFindWithCriteria(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->atLeastOnce()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('fetchAllAssociative') + ->willReturn([]); + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM subscriptions WHERE (id IN (:ids)) AND (group_name IN (:groups)) AND (status IN (:status)) ORDER BY id', + [ + 'ids' => ['foo'], + 'groups' => ['default'], + 'status' => ['active'], + ], + $this->isArray(), + ) + ->willReturn($result); + + $store = new DoctrineSubscriptionStore($connection); + + self::assertSame([], $store->find(new SubscriptionCriteria(['foo'], ['default'], [Status::Active]))); + } + + public function testClaim(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->atLeastOnce()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('fetchAssociative') + ->willReturn([ + 'id' => 'foo', + 'group_name' => 'default', + 'run_mode' => 'from_beginning', + 'position' => null, + 'status' => 'active', + 'error_message' => null, + 'error_previous_status' => null, + 'error_context' => null, + 'retry_attempt' => 0, + 'last_saved_at' => '2024-01-01 10:00:00', + 'cleanup_tasks' => null, + ]); + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM subscriptions WHERE (id = :id) AND (status IN (:status))', + [ + 'id' => 'foo', + 'status' => ['active'], + ], + $this->isArray(), + ) + ->willReturn($result); + + $store = new DoctrineSubscriptionStore($connection); + + $subscription = $store->claim('foo', new SubscriptionCriteria(status: [Status::Active])); + + self::assertInstanceOf(Subscription::class, $subscription); + self::assertSame('foo', $subscription->id()); + } + + public function testClaimNotFound(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->atLeastOnce()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('fetchAssociative') + ->willReturn(false); + $connection + ->expects($this->once()) + ->method('executeQuery') + ->willReturn($result); + + $store = new DoctrineSubscriptionStore($connection); + + self::assertNull($store->claim('foo', new SubscriptionCriteria(status: [Status::Active]))); + } + + public function testAdd(): void + { + $now = new DateTimeImmutable('2024-01-01 10:00:00'); + + $clock = $this->createMock(ClockInterface::class); + $clock + ->expects($this->once()) + ->method('now') + ->willReturn($now); + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('insert') + ->with( + 'subscriptions', + [ + 'id' => 'foo', + 'group_name' => 'default', + 'run_mode' => 'from_beginning', + 'status' => 'new', + 'position' => null, + 'error_message' => null, + 'error_previous_status' => null, + 'error_context' => null, + 'retry_attempt' => 0, + 'last_saved_at' => $now, + 'cleanup_tasks' => null, + ], + ['last_saved_at' => Types::DATETIME_IMMUTABLE], + ); + + $store = new DoctrineSubscriptionStore($connection, $clock); + + $store->add(new Subscription('foo')); + } + + public function testAddWithError(): void + { + $now = new DateTimeImmutable('2024-01-01 10:00:00'); + + $clock = $this->createMock(ClockInterface::class); + $clock + ->expects($this->once()) + ->method('now') + ->willReturn($now); + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('insert') + ->with( + 'subscriptions', + [ + 'id' => 'foo', + 'group_name' => 'default', + 'run_mode' => 'from_beginning', + 'status' => 'error', + 'position' => null, + 'error_message' => 'something went wrong', + 'error_previous_status' => 'active', + 'error_context' => '[]', + 'retry_attempt' => 0, + 'last_saved_at' => $now, + 'cleanup_tasks' => null, + ], + ['last_saved_at' => Types::DATETIME_IMMUTABLE], + ); + + $store = new DoctrineSubscriptionStore($connection, $clock); + + $store->add(new Subscription( + 'foo', + status: Status::Error, + error: new SubscriptionError('something went wrong', Status::Active, []), + )); + } + + public function testUpdate(): void + { + $now = new DateTimeImmutable('2024-01-01 10:00:00'); + + $clock = $this->createMock(ClockInterface::class); + $clock + ->expects($this->once()) + ->method('now') + ->willReturn($now); + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('update') + ->with( + 'subscriptions', + [ + 'group_name' => 'default', + 'run_mode' => 'from_beginning', + 'status' => 'new', + 'position' => null, + 'error_message' => null, + 'error_previous_status' => null, + 'error_context' => null, + 'retry_attempt' => 0, + 'last_saved_at' => $now, + 'cleanup_tasks' => null, + ], + ['id' => 'foo'], + ['last_saved_at' => Types::DATETIME_IMMUTABLE], + ) + ->willReturn(1); + + $store = new DoctrineSubscriptionStore($connection, $clock); + + $store->update(new Subscription('foo')); + } + + public function testUpdateNotFound(): void + { + $now = new DateTimeImmutable('2024-01-01 10:00:00'); + + $clock = $this->createMock(ClockInterface::class); + $clock + ->expects($this->once()) + ->method('now') + ->willReturn($now); + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('update') + ->willReturn(0); + + $store = new DoctrineSubscriptionStore($connection, $clock); + + $this->expectException(SubscriptionNotFound::class); + + $store->update(new Subscription('foo')); + } + + public function testRemove(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('delete') + ->with('subscriptions', ['id' => 'foo']); + + $store = new DoctrineSubscriptionStore($connection); + + $store->remove(new Subscription('foo')); + } + + public function testInLock(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('beginTransaction'); + $connection + ->expects($this->once()) + ->method('commit'); + + $store = new DoctrineSubscriptionStore($connection); + + $value = new DateTimeImmutable(); + + self::assertSame($value, $store->inLock(static fn (): DateTimeImmutable => $value)); + } + + public function testInLockRetryableException(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('beginTransaction'); + $connection + ->expects($this->once()) + ->method('commit'); + + $store = new DoctrineSubscriptionStore($connection); + + $this->expectException(TransactionCommitNotPossible::class); + + $store->inLock(static function (): void { + throw new DeadlockException(new Exception('deadlock'), null); + }); + } + + public function testInLockCommitNotPossible(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('beginTransaction'); + $connection + ->expects($this->once()) + ->method('commit') + ->willThrowException(new DriverException(new Exception('commit failed'), null)); + + $store = new DoctrineSubscriptionStore($connection); + + $this->expectException(TransactionCommitNotPossible::class); + + $store->inLock(static fn (): string => 'result'); + } + + public function testConfigureSchema(): void + { + $connection = $this->createMock(Connection::class); + + $store = new DoctrineSubscriptionStore($connection); + + $expectedSchema = new Schema(); + $table = $expectedSchema->createTable('subscriptions'); + $table->addColumn('id', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $table->addColumn('group_name', Types::STRING) + ->setLength(32) + ->setNotnull(true); + $table->addColumn('run_mode', Types::STRING) + ->setLength(16) + ->setNotnull(true); + $table->addColumn('position', Types::INTEGER) + ->setNotnull(false); + $table->addColumn('status', Types::STRING) + ->setLength(32) + ->setNotnull(true); + $table->addColumn('error_message', Types::TEXT) + ->setNotnull(false); + $table->addColumn('error_previous_status', Types::STRING) + ->setLength(32) + ->setNotnull(false); + $table->addColumn('error_context', Types::JSON) + ->setNotnull(false); + $table->addColumn('retry_attempt', Types::INTEGER) + ->setNotnull(true); + $table->addColumn('last_saved_at', Types::DATETIMETZ_IMMUTABLE) + ->setNotnull(true); + $table->addColumn('cleanup_tasks', Types::TEXT) + ->setNotnull(false); + + $table->setPrimaryKey(['id']); + $table->addIndex(['group_name']); + $table->addIndex(['status']); + + $schema = new Schema(); + $store->configureSchema($schema, $connection); + + self::assertEquals($expectedSchema, $schema); + } + + public function testConfigureSchemaWithDifferentDatabase(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db']); + + $differentConnection = $this->createMock(Connection::class); + $differentConnection + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db2']); + + $store = new DoctrineSubscriptionStore($connection); + + $schema = new Schema(); + $store->configureSchema($schema, $differentConnection); + + self::assertEquals(new Schema(), $schema); + } +} diff --git a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php index 274af10fa..67990b799 100644 --- a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php @@ -173,4 +173,41 @@ public function testClear(): void self::assertSame([], $store->find()); } + + public function testClaim(): void + { + $subscription = new Subscription('foo', status: Status::Active); + + $store = new InMemorySubscriptionStore([$subscription]); + + self::assertSame( + $subscription, + $store->claim('foo', new SubscriptionCriteria(status: [Status::Active])), + ); + } + + public function testClaimNotFound(): void + { + $store = new InMemorySubscriptionStore(); + + self::assertNull($store->claim('foo', new SubscriptionCriteria())); + } + + public function testClaimCriteriaNotMatched(): void + { + $subscription = new Subscription('foo', status: Status::New); + + $store = new InMemorySubscriptionStore([$subscription]); + + self::assertNull($store->claim('foo', new SubscriptionCriteria(status: [Status::Active]))); + } + + public function testInLock(): void + { + $store = new InMemorySubscriptionStore(); + + $subscription = new Subscription('foo'); + + self::assertSame($subscription, $store->inLock(static fn (): Subscription => $subscription)); + } } diff --git a/tests/Unit/Subscription/Store/SubscriptionCriteriaTest.php b/tests/Unit/Subscription/Store/SubscriptionCriteriaTest.php index 48c3bc766..9aa7e87f1 100644 --- a/tests/Unit/Subscription/Store/SubscriptionCriteriaTest.php +++ b/tests/Unit/Subscription/Store/SubscriptionCriteriaTest.php @@ -4,18 +4,29 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Store; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; +use Patchlevel\EventSourcing\Subscription\Status; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -#[CoversClass(SubscriptionEngineCriteria::class)] +#[CoversClass(SubscriptionCriteria::class)] final class SubscriptionCriteriaTest extends TestCase { - public function testSubscriptionId(): void + public function testInstantiate(): void { - $id = 'test'; - $criteria = new SubscriptionEngineCriteria([$id]); + $criteria = new SubscriptionCriteria(['foo'], ['bar'], [Status::Active]); - self::assertEquals([$id], $criteria->ids); + self::assertSame(['foo'], $criteria->ids); + self::assertSame(['bar'], $criteria->groups); + self::assertSame([Status::Active], $criteria->status); + } + + public function testInstantiateWithDefaults(): void + { + $criteria = new SubscriptionCriteria(); + + self::assertNull($criteria->ids); + self::assertNull($criteria->groups); + self::assertNull($criteria->status); } } diff --git a/tests/Unit/Subscription/Store/TransactionCommitNotPossibleTest.php b/tests/Unit/Subscription/Store/TransactionCommitNotPossibleTest.php new file mode 100644 index 000000000..d66b3435d --- /dev/null +++ b/tests/Unit/Subscription/Store/TransactionCommitNotPossibleTest.php @@ -0,0 +1,26 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + self::assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/Unit/Subscription/Subscriber/ArgumentResolver/ArgumentResolverContextTest.php b/tests/Unit/Subscription/Subscriber/ArgumentResolver/ArgumentResolverContextTest.php new file mode 100644 index 000000000..c4dc038ea --- /dev/null +++ b/tests/Unit/Subscription/Subscriber/ArgumentResolver/ArgumentResolverContextTest.php @@ -0,0 +1,32 @@ +message); + self::assertSame($subscription, $context->subscription); + self::assertSame($metadata, $context->subscriber); + } +} diff --git a/tests/Unit/Subscription/Subscriber/BatchNotFoundTest.php b/tests/Unit/Subscription/Subscriber/BatchNotFoundTest.php new file mode 100644 index 000000000..a7b3d906e --- /dev/null +++ b/tests/Unit/Subscription/Subscriber/BatchNotFoundTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/Subscriber/DuplicateSubscriberIdTest.php b/tests/Unit/Subscription/Subscriber/DuplicateSubscriberIdTest.php new file mode 100644 index 000000000..b64ade770 --- /dev/null +++ b/tests/Unit/Subscription/Subscriber/DuplicateSubscriberIdTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php b/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php index 9f3e5a424..8fdde8775 100644 --- a/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php +++ b/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php @@ -4,6 +4,8 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Subscriber; +use Patchlevel\EventSourcing\Attribute\Cleanup; +use Patchlevel\EventSourcing\Attribute\OnFailed; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; @@ -15,6 +17,9 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileVisited; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; +use stdClass; +use Throwable; #[CoversClass(MetadataSubscriberAccessor::class)] final class MetadataSubscriberAccessorTest extends TestCase @@ -121,4 +126,117 @@ class { self::assertEquals($subscriber, $this->accessor($subscriber)->subscriber()); } + + public function testMetadata(): void + { + $subscriber = new #[Subscriber('profile', RunMode::FromBeginning)] + class { + }; + + $accessor = $this->accessor($subscriber); + + self::assertSame('profile', $accessor->metadata()->id); + self::assertSame($subscriber, $accessor->subscriber()); + } + + public function testCleanupMethod(): void + { + $subscriber = new #[Subscriber('profile', RunMode::FromBeginning)] + class { + public bool $called = false; + + #[Cleanup] + public function cleanup(): void + { + $this->called = true; + } + }; + + $cleanupMethod = $this->accessor($subscriber)->cleanupMethod(); + + self::assertNotNull($cleanupMethod); + + $cleanupMethod(); + + self::assertTrue($subscriber->called); + } + + public function testNoCleanupMethod(): void + { + $subscriber = new #[Subscriber('profile', RunMode::FromBeginning)] + class { + }; + + self::assertNull($this->accessor($subscriber)->cleanupMethod()); + } + + public function testFailedMethod(): void + { + $subscriber = new #[Subscriber('profile', RunMode::FromBeginning)] + class { + public bool $called = false; + public Message|null $message = null; + public Throwable|null $throwable = null; + + #[OnFailed] + public function onFailed(Message $message, Throwable $throwable): void + { + $this->called = true; + $this->message = $message; + $this->throwable = $throwable; + } + }; + + $failedMethod = $this->accessor($subscriber)->failedMethod(); + $message = new Message(new stdClass()); + $throwable = new RuntimeException(); + + self::assertNotNull($failedMethod); + + $failedMethod($message, $throwable); + + self::assertTrue($subscriber->called); + self::assertSame($message, $subscriber->message); + self::assertSame($throwable, $subscriber->throwable); + } + + public function testNoFailedMethod(): void + { + $subscriber = new #[Subscriber('profile', RunMode::FromBeginning)] + class { + }; + + self::assertNull($this->accessor($subscriber)->failedMethod()); + } + + public function testEvents(): void + { + $subscriber = new #[Subscriber('profile', RunMode::FromBeginning)] + class { + #[Subscribe(ProfileVisited::class)] + public function onProfileVisited(Message $message): void + { + } + }; + + self::assertSame([ProfileVisited::class], $this->accessor($subscriber)->events()); + } + + public function testSubscribeMethodsCache(): void + { + $subscriber = new #[Subscriber('profile', RunMode::FromBeginning)] + class { + #[Subscribe(ProfileVisited::class)] + public function onProfileVisited(Message $message): void + { + } + }; + + $accessor = $this->accessor($subscriber); + + self::assertSame( + $accessor->subscribeMethods(ProfileVisited::class), + $accessor->subscribeMethods(ProfileVisited::class), + ); + } } diff --git a/tests/Unit/Subscription/Subscriber/NoSuitableResolverTest.php b/tests/Unit/Subscription/Subscriber/NoSuitableResolverTest.php new file mode 100644 index 000000000..19fdde13b --- /dev/null +++ b/tests/Unit/Subscription/Subscriber/NoSuitableResolverTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/SubscriptionTest.php b/tests/Unit/Subscription/SubscriptionTest.php index 39f6611f9..d28b2a314 100644 --- a/tests/Unit/Subscription/SubscriptionTest.php +++ b/tests/Unit/Subscription/SubscriptionTest.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription; +use DateTimeImmutable; use Patchlevel\EventSourcing\Subscription\NoErrorToRetry; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; @@ -13,6 +14,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use RuntimeException; +use stdClass; #[CoversClass(Subscription::class)] final class SubscriptionTest extends TestCase @@ -147,4 +149,119 @@ public function testDoRetry(): void self::assertEquals(null, $subscription->retryAttempt()); } + + public function testGroupAndRunMode(): void + { + $subscription = new Subscription('foo'); + + self::assertSame('foo', $subscription->subscriberId()); + self::assertSame(Subscription::DEFAULT_GROUP, $subscription->group()); + self::assertSame(RunMode::FromBeginning, $subscription->runMode()); + + $subscription->changeGroup('other'); + $subscription->changeRunMode(RunMode::Once); + + self::assertSame('other', $subscription->group()); + self::assertSame(RunMode::Once, $subscription->runMode()); + } + + public function testNew(): void + { + $subscription = new Subscription('foo', status: Status::Error); + + $subscription->new(); + + self::assertTrue($subscription->isNew()); + self::assertNull($subscription->subscriptionError()); + } + + public function testPause(): void + { + $subscription = new Subscription('foo'); + + $subscription->pause(); + + self::assertTrue($subscription->isPaused()); + self::assertSame(Status::Paused, $subscription->status()); + } + + public function testFinished(): void + { + $subscription = new Subscription('foo'); + + $subscription->finished(); + + self::assertTrue($subscription->isFinished()); + self::assertNull($subscription->subscriptionError()); + } + + public function testErrorWithString(): void + { + $subscription = new Subscription('foo', status: Status::Active); + + $subscription->error('something went wrong'); + + self::assertTrue($subscription->isError()); + + $error = $subscription->subscriptionError(); + + self::assertNotNull($error); + self::assertSame('something went wrong', $error->errorMessage); + self::assertSame(Status::Active, $error->previousStatus); + } + + public function testFailedWithThrowable(): void + { + $subscription = new Subscription('foo', status: Status::Active); + + $subscription->failed(new RuntimeException('something went wrong')); + + self::assertTrue($subscription->isFailed()); + + $error = $subscription->subscriptionError(); + + self::assertNotNull($error); + self::assertSame('something went wrong', $error->errorMessage); + self::assertSame(Status::Active, $error->previousStatus); + } + + public function testFailedWithString(): void + { + $subscription = new Subscription('foo', status: Status::Active); + + $subscription->failed('something went wrong'); + + self::assertTrue($subscription->isFailed()); + + $error = $subscription->subscriptionError(); + + self::assertNotNull($error); + self::assertSame('something went wrong', $error->errorMessage); + } + + public function testLastSavedAt(): void + { + $subscription = new Subscription('foo'); + + self::assertNull($subscription->lastSavedAt()); + + $now = new DateTimeImmutable(); + $subscription->updateLastSavedAt($now); + + self::assertSame($now, $subscription->lastSavedAt()); + } + + public function testCleanupTasks(): void + { + $subscription = new Subscription('foo'); + + self::assertNull($subscription->cleanupTasks()); + self::assertFalse($subscription->hasCleanupTasks()); + + $task = new stdClass(); + $subscription->replaceCleanupTasks([$task]); + + self::assertSame([$task], $subscription->cleanupTasks()); + self::assertTrue($subscription->hasCleanupTasks()); + } }