diff --git a/Makefile b/Makefile index d43cf34..c85e2d6 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ phpcs: ## Run PHP CS Fixer test: ## Run code tests ./vendor/bin/phpunit --testdox -phpstan: +phpstan: ## Run PHPStan static analysis ./vendor/bin/phpstan analyse src --level=9 test-phpcs: ## Run coding standard tests diff --git a/README.md b/README.md index 5c45173..9d8993c 100644 --- a/README.md +++ b/README.md @@ -4,30 +4,33 @@ A Symfony Bundle to use when you want to assert that messages used with Message ## Getting started ### Installation -You can easily install Zdd Message bundle by composer +First, install the bundle with composer: ``` $ composer require yousign/zdd-message-bundle ``` -Then, bundle should be registered. Just verify that `config\bundles.php` is containing : + +Then, verify that the bundle has been registered in `config/bundles.php`: ```php Yousign\ZddMessageBundle\ZddMessageBundle::class => ['all' => true], ``` ### Configuration -Once the bundle is installed, you should create a class to configure the messages to assert and how to create them: +Create a class to configure the messages to assert and how to create them: ```php null, }; } + + /** + * Optional: Implement CustomMessageGeneratorInterface if you need full control + * over how a specific message instance is created. + * This is useful when the default instantiation (using reflection and property injection) + * is not sufficient or when your message requires specific constructor logic. + * + * WARNING: The object must be instantiated with minimum requirements (i.e., nullable properties + * must be set as null) in order to ensure a good ZDD test. + */ + #[\Override] + public function generateCustomMessage(string $className): ?object + { + return match ($className) { + App\Message\ComplexMessage::class => new App\Message\ComplexMessage('custom data'), + default => null, + }; + } } ``` -When the class is created, you can register it as a service. +Then, register this class as a service. ```yaml # config/services.yaml - App\Message\MessageConfig: ~ + App\Message\MessageConfig: ``` -Then, you should register it in the configuration (`config/packages/zdd_message.yaml`) : +Finish by updating the configuration with this new service in `config/packages/zdd_message.yaml`: ```yaml # config/packages/zdd_message.yaml zdd_message: - serialized_messages_dir: 'var/serialized_messages' # The directory where the serialized messages will be stored (default: '%kernel.logs_dir%') + message_config_service: App\Message\MessageConfig ``` #### Optional configuration @@ -71,7 +93,7 @@ Then, you should register it in the configuration (`config/packages/zdd_message. **Use a custom serializer** Option to use different serializer. -Possible options : +Possible options: - `Yousign\ZddMessageBundle\Serializer\ZddMessageMessengerSerializer` (default, already configured for messenger serialization in messenger.yaml) - Define your own serializer - Create a service that implement `Yousign\ZddMessageBundle\Serializer\SerializerInterface` @@ -82,6 +104,17 @@ Possible options : serializer: '' ``` +**Custom directory for serialized messages** + +Option to specify a custom directory where serialized messages will be stored. + +```yaml +# config/packages/zdd_message.yaml +zdd_message: + # ... + serialized_messages_dir: '%kernel.project_dir%/custom/path' # Default: '%kernel.project_dir%/var/zdd-message' +``` + **Detect messages not tracked** Option to write a log message if an asynchronous message has been sent (using symfony messenger) and is not present in your configuration. diff --git a/src/Config/CustomMessageGeneratorInterface.php b/src/Config/CustomMessageGeneratorInterface.php new file mode 100644 index 0000000..309df45 --- /dev/null +++ b/src/Config/CustomMessageGeneratorInterface.php @@ -0,0 +1,26 @@ + $className + * + * @return ?T The fully instantiated message object, or null if the default instantiation should be used + */ + public function generateCustomMessage(string $className): ?object; +} diff --git a/src/Factory/MessageGenerator.php b/src/Factory/MessageGenerator.php new file mode 100644 index 0000000..cbb2bbe --- /dev/null +++ b/src/Factory/MessageGenerator.php @@ -0,0 +1,79 @@ +propertyExtractor = new ZddPropertyExtractor(); + } + + /** + * @param class-string $className + */ + public function generate(string $className): object + { + if ($this->config instanceof CustomMessageGeneratorInterface) { + $message = $this->config->generateCustomMessage($className); + if (null !== $message) { + return $message; + } + } + + $message = (new \ReflectionClass($className))->newInstanceWithoutConstructor(); + $propertyList = $this->propertyExtractor->extractPropertiesFromClass($className); + + foreach ($propertyList->getProperties() as $property) { + $value = $property->isNullable ? null : $this->generateValueForProperty($property); + $this->forcePropertyValue($message, $property->name, $value); + } + + return $message; + } + + /** + * @throws MissingValueForTypeException + */ + private function generateValueForProperty(Property $property): mixed + { + $value = $this->config->generateValueForCustomPropertyType($property->type); + if (null !== $value) { + return $value; + } + + return match ($property->type) { + 'string' => 'Hello World!', + 'int' => 42, + 'float' => 42.42, + 'bool' => true, + 'array' => ['PHP', 'For The Win'], + default => throw MissingValueForTypeException::missingValue($property->type, $this->config), + }; + } + + private function forcePropertyValue(object $object, string $property, mixed $value): void + { + $reflectionClass = new \ReflectionClass($object); + $reflectionProperty = $reflectionClass->getProperty($property); + + // Readonly properties can only be set on the declaring class in case of inheritance + if ($reflectionProperty->isReadOnly()) { + $reflectionProperty = $reflectionProperty->getDeclaringClass()->getProperty($property); + } + + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($object, $value); + } +} diff --git a/src/Factory/Property.php b/src/Factory/Property.php index 0ecd324..c477961 100644 --- a/src/Factory/Property.php +++ b/src/Factory/Property.php @@ -4,7 +4,7 @@ final class Property { - public function __construct(public readonly string $name, public readonly string $type, public readonly mixed $value) + public function __construct(public readonly string $name, public readonly string $type, public readonly bool $isNullable) { } } diff --git a/src/Factory/PropertyList.php b/src/Factory/PropertyList.php index 10d7e23..22d8cb6 100644 --- a/src/Factory/PropertyList.php +++ b/src/Factory/PropertyList.php @@ -29,17 +29,21 @@ public function addProperty(Property $property): void public static function fromJson(string $data): self { - /** @var array> $decodedProperties */ + /** @var array< + * array{name?: string, type?: string, isNullable?: bool} + * > $decodedProperties + */ $decodedProperties = \json_decode($data, true); $properties = []; foreach ($decodedProperties as $decodedProperty) { $name = $decodedProperty['name'] ?? null; $type = $decodedProperty['type'] ?? null; + $isNullable = $decodedProperty['isNullable'] ?? null; - if (null === $name || null === $type) { - throw new \LogicException(sprintf('Missing keys name and/or type in decoded properties from data: "%s"', $data)); + if (null === $name || null === $type || null === $isNullable) { + throw new \LogicException(sprintf('Missing keys name and/or type and/or isNullable in decoded properties from data: "%s"', $data)); } - $properties[] = new Property($decodedProperty['name'], $decodedProperty['type'], null); + $properties[] = new Property($name, $type, $isNullable); } return new self($properties); @@ -94,6 +98,7 @@ public function toJson(): string $data[] = [ 'name' => $property->name, 'type' => $property->type, + 'isNullable' => $property->isNullable, ]; } diff --git a/src/Factory/ZddMessageFactory.php b/src/Factory/ZddMessageFactory.php index a2b6adb..905a317 100644 --- a/src/Factory/ZddMessageFactory.php +++ b/src/Factory/ZddMessageFactory.php @@ -13,10 +13,12 @@ final class ZddMessageFactory { private ZddPropertyExtractor $propertyExtractor; + private MessageGenerator $messageGenerator; public function __construct(ZddMessageConfigInterface $config, private readonly SerializerInterface $serializer) { - $this->propertyExtractor = new ZddPropertyExtractor($config); + $this->propertyExtractor = new ZddPropertyExtractor(); + $this->messageGenerator = new MessageGenerator($config); } /** @@ -26,10 +28,9 @@ public function create(string $className): ZddMessage { try { $propertyList = $this->propertyExtractor->extractPropertiesFromClass($className); - $message = (new \ReflectionClass($className))->newInstanceWithoutConstructor(); - foreach ($propertyList->getProperties() as $property) { - $this->forcePropertyValue($message, $property->name, $property->value); - } + + $message = $this->messageGenerator->generate($className); + $serializedMessage = $this->serializer->serialize($message); } catch (\Throwable $e) { throw new \LogicException('Unable to create ZddMessage for class "'.$className.'"', previous: $e); @@ -37,18 +38,4 @@ public function create(string $className): ZddMessage return new ZddMessage($className, $serializedMessage, $propertyList, $message); } - - private function forcePropertyValue(object $object, string $property, mixed $value): void - { - $reflectionClass = new \ReflectionClass($object); - $reflectionProperty = $reflectionClass->getProperty($property); - - // Readonly properties can only be set on the declaring class in case of inheritance - if ($reflectionProperty->isReadOnly()) { - $reflectionProperty = $reflectionProperty->getDeclaringClass()->getProperty($property); - } - - $reflectionProperty->setAccessible(true); - $reflectionProperty->setValue($object, $value); - } } diff --git a/src/Factory/ZddPropertyExtractor.php b/src/Factory/ZddPropertyExtractor.php index 38d1f5b..e3f22cc 100644 --- a/src/Factory/ZddPropertyExtractor.php +++ b/src/Factory/ZddPropertyExtractor.php @@ -2,24 +2,17 @@ namespace Yousign\ZddMessageBundle\Factory; -use Yousign\ZddMessageBundle\Config\ZddMessageConfigInterface; use Yousign\ZddMessageBundle\Exceptions\InvalidTypeException; -use Yousign\ZddMessageBundle\Exceptions\MissingValueForTypeException; /** * @internal */ final class ZddPropertyExtractor { - public function __construct(private readonly ZddMessageConfigInterface $config) - { - } - /** * @param class-string $className * * @throws InvalidTypeException - * @throws MissingValueForTypeException * @throws \ReflectionException */ public function extractPropertiesFromClass(string $className): PropertyList @@ -40,31 +33,15 @@ public function extractPropertiesFromClass(string $className): PropertyList throw InvalidTypeException::typeNotSupported(); } - $typeHint = $propertyType->getName(); - $value = $propertyType->allowsNull() ? null : $this->generateFakeValueFromType($typeHint); - $propertyList->addProperty(new Property($propertyName, $typeHint, $value)); + $propertyList->addProperty( + new Property( + $propertyName, + $propertyType->getName(), + $propertyType->allowsNull(), + ), + ); } return $propertyList; } - - /** - * @throws MissingValueForTypeException - */ - private function generateFakeValueFromType(string $typeHint): mixed - { - $value = $this->config->generateValueForCustomPropertyType($typeHint); - if (null !== $value) { - return $value; - } - - return match ($typeHint) { - 'string' => 'Hello World!', - 'int' => 42, - 'float' => 42.42, - 'bool' => true, - 'array' => ['PHP', 'For The Win'], - default => throw MissingValueForTypeException::missingValue($typeHint, $this->config), - }; - } } diff --git a/src/ZddMessageBundle.php b/src/ZddMessageBundle.php index d63148b..b513966 100644 --- a/src/ZddMessageBundle.php +++ b/src/ZddMessageBundle.php @@ -21,7 +21,6 @@ final class ZddMessageBundle extends AbstractBundle { public function configure(DefinitionConfigurator $definition): void { - /* @phpstan-ignore-next-line */ $definition ->rootNode() ->children() diff --git a/tests/Fixtures/App/Messages/Config/MessageConfig.php b/tests/Fixtures/App/Messages/Config/MessageConfig.php index a09f596..a271f66 100644 --- a/tests/Fixtures/App/Messages/Config/MessageConfig.php +++ b/tests/Fixtures/App/Messages/Config/MessageConfig.php @@ -2,7 +2,9 @@ namespace Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\Config; +use Yousign\ZddMessageBundle\Config\CustomMessageGeneratorInterface; use Yousign\ZddMessageBundle\Config\ZddMessageConfigInterface; +use Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyCustomMessage; use Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyMessage; use Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyMessageWithAllManagedTypes; use Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyMessageWithNullableNumberProperty; @@ -11,10 +13,11 @@ use Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\Input\Status; use Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\Other; -class MessageConfig implements ZddMessageConfigInterface +class MessageConfig implements ZddMessageConfigInterface, CustomMessageGeneratorInterface { public static array $messagesToAssert = []; + #[\Override] public function getMessageToAssert(): array { return [] !== self::$messagesToAssert ? self::$messagesToAssert : [ @@ -23,9 +26,11 @@ public function getMessageToAssert(): array DummyMessageWithPrivateConstructor::class, DummyMessageWithAllManagedTypes::class, Other\DummyMessage::class, + DummyCustomMessage::class, ]; } + #[\Override] public function generateValueForCustomPropertyType(string $type): mixed { return match ($type) { @@ -40,4 +45,14 @@ public static function reset(): void { self::$messagesToAssert = []; } + + #[\Override] + public function generateCustomMessage(string $className): ?object + { + if (DummyCustomMessage::class === $className) { + return new DummyCustomMessage('custom message data'); + } + + return null; + } } diff --git a/tests/Fixtures/App/Messages/DummyCustomMessage.php b/tests/Fixtures/App/Messages/DummyCustomMessage.php new file mode 100644 index 0000000..b614e4f --- /dev/null +++ b/tests/Fixtures/App/Messages/DummyCustomMessage.php @@ -0,0 +1,18 @@ +content = $content; + } + + public function getContent(): string + { + return $this->content; + } +} diff --git a/tests/Func/GenerateZddMessageCommandTest.php b/tests/Func/GenerateZddMessageCommandTest.php index 4961145..c78a5a1 100644 --- a/tests/Func/GenerateZddMessageCommandTest.php +++ b/tests/Func/GenerateZddMessageCommandTest.php @@ -50,6 +50,7 @@ public function testThatCommandIsSuccessful(): void 3 Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyMessageWithPrivateConstructor 4 Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyMessageWithAllManagedTypes 5 Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\Other\DummyMessage + 6 Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyCustomMessage --- --------------------------------------------------------------------------------------------- EOF; diff --git a/tests/Func/ListZddMessageCommandTest.php b/tests/Func/ListZddMessageCommandTest.php index 3670f7a..a2681e4 100644 --- a/tests/Func/ListZddMessageCommandTest.php +++ b/tests/Func/ListZddMessageCommandTest.php @@ -31,6 +31,7 @@ public function testCommandIsSuccess(): void 3 Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyMessageWithPrivateConstructor 4 Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyMessageWithAllManagedTypes 5 Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\Other\DummyMessage + 6 Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyCustomMessage --- --------------------------------------------------------------------------------------------- EOF; diff --git a/tests/Func/ValidateZddMessageCommandTest.php b/tests/Func/ValidateZddMessageCommandTest.php index 3b1d366..5e1c044 100644 --- a/tests/Func/ValidateZddMessageCommandTest.php +++ b/tests/Func/ValidateZddMessageCommandTest.php @@ -49,7 +49,7 @@ public function testThatCommandIsSuccessful(): void { mkdir($this->serializedMessagesDir); file_put_contents($this->serializedMessagesDir.'/DummyMessage.txt', $this->getSerializer()->serialize(new DummyMessage('Hi'))); - file_put_contents($this->serializedMessagesDir.'/DummyMessage.properties.json', '[{"name":"content","type":"string"}]'); + file_put_contents($this->serializedMessagesDir.'/DummyMessage.properties.json', '[{"name":"content","type":"string","isNullable":false}]'); $this->assertSerializedFilesExist($this->serializedMessagesDir); $this->command->execute([]); @@ -92,10 +92,12 @@ public function testThatCommandFailsWhenMessageIsNotZddCompliant(): void [ 'name' => 'content', 'type' => 'string', + 'isNullable' => false, ], [ 'name' => 'number', 'type' => 'int', + 'isNullable' => false, ], ]; mkdir($this->serializedMessagesDir); @@ -126,10 +128,12 @@ public function testThatErrorTableIsShownInVerboseMode(): void [ 'name' => 'content', 'type' => 'string', + 'isNullable' => false, ], [ 'name' => 'number', 'type' => 'int', + 'isNullable' => false, ], ]; mkdir($this->serializedMessagesDir); diff --git a/tests/Unit/ZddMessageAsserterTest.php b/tests/Unit/ZddMessageAsserterTest.php index 7071a27..a7fab96 100644 --- a/tests/Unit/ZddMessageAsserterTest.php +++ b/tests/Unit/ZddMessageAsserterTest.php @@ -42,7 +42,8 @@ public function provideValidAssertion(): iterable [ { "name": "content", - "type": "string" + "type": "string", + "isNullable": false } ] JSON, @@ -55,11 +56,13 @@ public function provideValidAssertion(): iterable [ { "name": "content", - "type": "string" + "type": "string", + "isNullable": false }, { "name": "number", - "type": "int" + "type": "int", + "isNullable": false } ] JSON, @@ -86,11 +89,13 @@ private function getSerializedMessageForPreviousVersionOfDummyMessageWithNumberP [ { "name": "content", - "type": "string" + "type": "string", + "isNullable": false }, { "name": "number", - "type": "int" + "type": "int", + "isNullable": false } ] JSON; @@ -124,7 +129,8 @@ public function testItThrowsExceptionForInvalidIntegrationDueToPropertyTypeMisma [ { "name": "content", - "type": "int" + "type": "int", + "isNullable": false } ] JSON diff --git a/tests/Unit/ZddMessageFactoryTest.php b/tests/Unit/ZddMessageFactoryTest.php index cb18815..bc83d0c 100644 --- a/tests/Unit/ZddMessageFactoryTest.php +++ b/tests/Unit/ZddMessageFactoryTest.php @@ -8,6 +8,7 @@ use Yousign\ZddMessageBundle\Exceptions\MissingValueForTypeException; use Yousign\ZddMessageBundle\Factory\ZddMessageFactory; use Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\Config\MessageConfig; +use Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyCustomMessage; use Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyMessageWithAllManagedTypes; use Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyMessageWithInheritedReadonlyProperty; use Yousign\ZddMessageBundle\Tests\Fixtures\App\Messages\DummyMessageWithNullableNumberProperty; @@ -19,13 +20,18 @@ class ZddMessageFactoryTest extends TestCase { use SerializerTrait; - private readonly ZddMessageFactory $zddMessageFactory; + private ZddMessageFactory $zddMessageFactory; public function setUp(): void { $this->zddMessageFactory = new ZddMessageFactory(new MessageConfig(), $this->getSerializer()); } + protected function tearDown(): void + { + unset($this->zddMessageFactory); + } + public function testItGeneratesSerializedMessageWithNullAndNotNullableProperties(): void { $zddMessage = $this->zddMessageFactory->create(DummyMessageWithNullableNumberProperty::class); @@ -38,11 +44,9 @@ public function testItGeneratesSerializedMessageWithNullAndNotNullableProperties self::assertTrue($zddMessage->propertyList()->has('content')); $property = $zddMessage->propertyList()->get('content'); self::assertSame('string', $property->type); - self::assertSame('Hello World!', $property->value); $propertyNumber = $zddMessage->propertyList()->get('number'); self::assertSame('int', $propertyNumber->type); - self::assertNull($propertyNumber->value); } public function testItGeneratesSerializedMessageForDummyMessageWithInheritedReadonlyProperty(): void @@ -58,7 +62,6 @@ public function testItGeneratesSerializedMessageForDummyMessageWithInheritedRead self::assertTrue($zddMessage->propertyList()->has('content')); $property = $zddMessage->propertyList()->get('content'); self::assertSame('string', $property->type); - self::assertSame('Hello World!', $property->value); } public function testItGeneratesSerializedMessageForDummyMessageWithPrivateConstructor(): void @@ -73,7 +76,6 @@ public function testItGeneratesSerializedMessageForDummyMessageWithPrivateConstr self::assertTrue($zddMessage->propertyList()->has('content')); $property = $zddMessage->propertyList()->get('content'); self::assertSame('string', $property->type); - self::assertSame('Hello World!', $property->value); } public function testItGeneratesSerializedMessageForDummyMessageContainingAllManagedTypesWithoutError(): void @@ -112,6 +114,15 @@ public function testItThrownAMissingValueForTypeException(): void ); $factory->create(WithoutValue::class); } + + public function testGenerateCustomMessage(): void + { + $zddMessage = $this->zddMessageFactory->create(DummyCustomMessage::class); + self::assertEquals(1, $zddMessage->propertyList()->count()); + self::assertSame('string', $zddMessage->propertyList()->get('content')->type); + // See the expected message in Fixtures\App\Messages\Config\MessageConfig::generateCustomMessage(...) + self::assertEquals(new DummyCustomMessage('custom message data'), $zddMessage->message()); + } } namespace App\WithoutValue; @@ -128,6 +139,7 @@ public function __construct(private DummyMessage $dummyMessage) final class WithoutValueConfig implements ZddMessageConfigInterface { + #[\Override] public function getMessageToAssert(): array { return [ @@ -135,6 +147,7 @@ public function getMessageToAssert(): array ]; } + #[\Override] public function generateValueForCustomPropertyType(string $type): mixed { return null;