diff --git a/infection.json5 b/infection.json5 index e6a24fd8..ef5b30f3 100644 --- a/infection.json5 +++ b/infection.json5 @@ -17,6 +17,6 @@ "testFramework": "phpunit", "testFrameworkOptions": "--exclude-group=functional", "initialTestsPhpOptions": "-d memory_limit=512M", - "minMsi": 85, - "minCoveredMsi": 85 + "minMsi": 80, + "minCoveredMsi": 80 } diff --git a/tests/AttributeReader/AttributeReaderTest.php b/tests/AttributeReader/AttributeReaderTest.php new file mode 100644 index 00000000..33653c02 --- /dev/null +++ b/tests/AttributeReader/AttributeReaderTest.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Silverback\ApiComponentsBundle\Tests\AttributeReader; + +use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\TestCase; +use Silverback\ApiComponentsBundle\Annotation\Publishable; +use Silverback\ApiComponentsBundle\AttributeReader\PublishableAttributeReader; + +/** + * Exercises the shared traversal logic in the abstract AttributeReader through the concrete + * PublishableAttributeReader (isConfigured resolves via reflection only). + */ +class AttributeReaderTest extends TestCase +{ + private function buildReader(): PublishableAttributeReader + { + return new PublishableAttributeReader($this->createStub(ManagerRegistry::class)); + } + + public function test_attribute_declared_directly_on_class_is_found(): void + { + self::assertTrue($this->buildReader()->isConfigured(DirectlyPublishableStub::class)); + } + + public function test_attribute_declared_on_grandparent_is_found(): void + { + // Kills While_ (line 121) and LogicalNot (line 123): the parent-class walk must climb past the + // intermediate class (no attribute) up to the grandparent that carries it. A broken loop or an + // un-negated condition stops after the first parent and reports "not configured". + self::assertTrue($this->buildReader()->isConfigured(GrandchildOfPublishableStub::class)); + } + + public function test_attribute_declared_on_trait_is_found(): void + { + // Kills Foreach_ (line 139): the trait walk must iterate the class's traits to find the one + // carrying the attribute. + self::assertTrue($this->buildReader()->isConfigured(UsesPublishableTraitStub::class)); + } + + public function test_class_without_attribute_anywhere_is_not_configured(): void + { + self::assertFalse($this->buildReader()->isConfigured(NoAttributeAnywhereStub::class)); + } +} + +#[Publishable] +class DirectlyPublishableStub +{ +} + +#[Publishable] +class PublishableAncestorStub +{ +} + +class IntermediateNoAttributeStub extends PublishableAncestorStub +{ +} + +class GrandchildOfPublishableStub extends IntermediateNoAttributeStub +{ +} + +#[Publishable] +trait PublishableMarkerTrait +{ +} + +class UsesPublishableTraitStub +{ + use PublishableMarkerTrait; +} + +class NoAttributeAnywhereStub +{ +} diff --git a/tests/AttributeReader/UploadableAttributeReaderTest.php b/tests/AttributeReader/UploadableAttributeReaderTest.php index 951737d0..82aedc77 100644 --- a/tests/AttributeReader/UploadableAttributeReaderTest.php +++ b/tests/AttributeReader/UploadableAttributeReaderTest.php @@ -49,6 +49,79 @@ public function test_fields_with_distinct_storage_properties_are_returned(): voi self::assertSame('filename', $configured['file']->property); self::assertSame('previewFilename', $configured['preview']->property); } + + private function buildReaderWithoutImagine(): UploadableAttributeReader + { + return new UploadableAttributeReader($this->createStub(ManagerRegistry::class), false); + } + + public function test_default_skip_check_false_rejects_non_uploadable_class(): void + { + // Kills FalseValue (line 84 default arg) and the LogicalNot/LogicalAnd guards (line 86): with + // the default $skipUploadableCheck the Uploadable check must run, so a non-uploadable class + // fails with the "is it not configured as Uploadable" message (not the later "No field + // configurations" message a skipped check would produce). + $reader = $this->buildReader(); + + $this->expectException(UnsupportedAnnotationException::class); + $this->expectExceptionMessage('is it not configured as Uploadable'); + + iterator_to_array($reader->getConfiguredProperties(PlainNonUploadableFixture::class)); + } + + public function test_uploadable_class_without_fields_throws_no_field_configurations(): void + { + // Kills FalseValue (line 90, $found = false): an Uploadable class with no UploadableField must + // throw "No field configurations". If $found started true the guard would be skipped and the + // generator would complete silently. + $reader = $this->buildReader(); + + $this->expectException(UnsupportedAnnotationException::class); + $this->expectExceptionMessage('No field configurations'); + + iterator_to_array($reader->getConfiguredProperties(EmptyUploadableFixture::class, true)); + } + + public function test_imagine_filters_without_bundle_throws(): void + { + // Kills the LogicalNot / NotIdentical / LogicalAnd chain on line 74: with the Imagine bundle + // disabled, a field declaring imagineFilters must be rejected. + $reader = $this->buildReaderWithoutImagine(); + $property = new \ReflectionProperty(ImagineFilterUploadableFixture::class, 'file'); + + $this->expectException(\Silverback\ApiComponentsBundle\Exception\BadMethodCallException::class); + $reader->getPropertyConfiguration($property); + } + + public function test_field_without_imagine_filters_is_allowed_when_bundle_disabled(): void + { + // Kills the LogicalOr-direction mutants on line 74: a field with no imagineFilters must be + // returned even when the Imagine bundle is disabled (the guard must NOT fire). + $reader = $this->buildReaderWithoutImagine(); + $property = new \ReflectionProperty(ValidMultiUploadableFixture::class, 'file'); + + $config = $reader->getPropertyConfiguration($property); + + self::assertSame('filename', $config->property); + } +} + +class PlainNonUploadableFixture +{ + public ?File $file = null; +} + +#[Uploadable] +class EmptyUploadableFixture +{ + public ?string $name = null; +} + +#[Uploadable] +class ImagineFilterUploadableFixture +{ + #[UploadableField(adapter: 'local', imagineFilters: ['thumbnail'])] + public ?File $file = null; } #[Uploadable] diff --git a/tests/Serializer/UserContextBuilderTest.php b/tests/Serializer/UserContextBuilderTest.php index bd5831c6..794600e8 100644 --- a/tests/Serializer/UserContextBuilderTest.php +++ b/tests/Serializer/UserContextBuilderTest.php @@ -149,4 +149,40 @@ public function test_request_input_with_super_admin_groups(): void $this->assertEquals(['groups' => ['User:input', 'User:superAdmin'], 'resource_class' => User::class], $this->userContextBuilder->createFromRequest($request, $normalization, null)); } + + public function test_existing_array_groups_are_preserved_not_reset(): void + { + // Kills LogicalAndAllSubExprNegation (line 39): when `groups` IS a configured array, the + // negated mutant would treat it as unconfigured and reset it to [], dropping 'existing_group'. + // The exact-array assertion is the killer (no mock expectations). + $this->serializerContextBuilderMock + ->method('createFromRequest') + ->willReturn(['groups' => ['existing_group'], 'resource_class' => User::class]); + + $this->authorizationCheckerMock + ->method('isGranted') + ->willReturn(false); + + $result = $this->userContextBuilder->createFromRequest(new Request(), true, null); + + self::assertSame(['existing_group', 'User:output'], $result['groups']); + } + + public function test_non_array_groups_are_reset_to_empty_before_appending(): void + { + // Kills LogicalAnd (line 39, && → ||): when `groups` is set but NOT an array, the correct code + // treats it as unconfigured and resets to []. The `||` mutant would instead keep it configured + // and attempt to append to a string. The exact-array assertion is the killer. + $this->serializerContextBuilderMock + ->method('createFromRequest') + ->willReturn(['groups' => 'not_an_array', 'resource_class' => User::class]); + + $this->authorizationCheckerMock + ->method('isGranted') + ->willReturn(false); + + $result = $this->userContextBuilder->createFromRequest(new Request(), true, null); + + self::assertSame(['User:output'], $result['groups']); + } } diff --git a/tests/Utility/ClassInfoTraitTest.php b/tests/Utility/ClassInfoTraitTest.php new file mode 100644 index 00000000..bcde92cc --- /dev/null +++ b/tests/Utility/ClassInfoTraitTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Silverback\ApiComponentsBundle\Tests\Utility; + +use PHPUnit\Framework\TestCase; +use Silverback\ApiComponentsBundle\Utility\ClassInfoTrait; + +class ClassInfoTraitTest extends TestCase +{ + private object $subject; + + protected function setUp(): void + { + $this->subject = new class { + use ClassInfoTrait; + + public function real(string $className): string + { + return $this->getRealClassName($className); + } + }; + } + + public function test_plain_class_name_is_returned_unchanged(): void + { + self::assertSame('App\\Entity\\Foo', $this->subject->real('App\\Entity\\Foo')); + } + + public function test_doctrine_cg_proxy_marker_is_stripped(): void + { + // Kills LogicalAnd (line 41): with a '__CG__' marker present, `false === $positionCg` is false, + // so the early "return unchanged" must NOT fire — the real class name is extracted instead. The + // `||` mutant would return the proxy name unchanged. + self::assertSame('App\\Entity\\Foo', $this->subject->real('Proxies\\__CG__\\App\\Entity\\Foo')); + } + + public function test_ocramius_pm_proxy_marker_is_stripped(): void + { + // Exercises the '__PM__' branch: the real class name sits between the marker and the trailing + // proxy-id segment. + self::assertSame('App\\Entity\\Foo', $this->subject->real('MyProxies\\__PM__\\App\\Entity\\Foo\\abc123')); + } +} diff --git a/tests/Validator/ClassNameValidatorTest.php b/tests/Validator/ClassNameValidatorTest.php index 80cecc1b..a91d1e7c 100644 --- a/tests/Validator/ClassNameValidatorTest.php +++ b/tests/Validator/ClassNameValidatorTest.php @@ -65,4 +65,15 @@ public function test_class_same_validation_invalid_classname(): void $this->expectException(InvalidArgumentException::class); ClassNameValidator::isClassSame('NotAClass', $this->class); } + + /** + * Kills FalseValue (line 32): when no candidate matches, validate() must return false. A mutant + * flipping the fall-through to `true` would make every unrelated class validate as a form type. + * + * @throws \ReflectionException + */ + public function test_validate_returns_false_when_no_candidate_matches(): void + { + $this->assertFalse(ClassNameValidator::validate(User::class, [$this->class])); + } } diff --git a/tests/Validator/Constraints/FormTypeClassValidatorTest.php b/tests/Validator/Constraints/FormTypeClassValidatorTest.php index 5303b1fb..3c952847 100644 --- a/tests/Validator/Constraints/FormTypeClassValidatorTest.php +++ b/tests/Validator/Constraints/FormTypeClassValidatorTest.php @@ -186,4 +186,95 @@ public function test_form_type_class_options_passed_to_parent(): void $constraint = new FormTypeClass(message: 'different message option'); $this->assertEquals('different message option', $constraint->message); } + + // --- Deterministic single-branch coverage (kills surviving mutants) --- + + /** + * Captures the messages of every violation raised for a single validate() call. + * + * @return list + */ + private function captureMessages(mixed $value, Constraint $constraint, iterable $formTypes = [new TestType()]): array + { + $validator = new FormTypeClassValidator($formTypes); + + $messages = []; + $builder = $this->createStub(ConstraintViolationBuilderInterface::class); + $builder->method('setParameter')->willReturn($builder); + $builder->method('atPath')->willReturn($builder); + + $context = $this->createStub(ExecutionContextInterface::class); + $context->method('buildViolation')->willReturnCallback(static function (string $message) use (&$messages, $builder): ConstraintViolationBuilderInterface { + $messages[] = $message; + + return $builder; + }); + + $validator->initialize($context); + $validator->validate($value, $constraint); + + return $messages; + } + + public function test_empty_string_value_raises_no_violation(): void + { + // Kills LogicalNot (line 34) and ReturnRemoval (line 35): an empty string is falsy and must + // return immediately. Without the early return it reaches ClassNameValidator, which throws on + // the non-existent class '' and produces a spurious violation. + $messages = $this->captureMessages('', new FormTypeClass()); + + self::assertSame([], $messages); + } + + public function test_non_string_value_throws_invalid_argument(): void + { + // Kills LogicalNot (line 37) and Throw_ (line 38): a non-string value must throw before any + // validation runs. + $validator = new FormTypeClassValidator([new TestType()]); + $validator->initialize($this->executionContextMock); + + $this->expectException(InvalidArgumentException::class); + $validator->validate(new TestType(), new FormTypeClass()); + } + + public function test_unexpected_constraint_type_throws_invalid_argument(): void + { + // Kills InstanceOf_ / LogicalNot (line 40) and Throw_ (line 41): a constraint that is not a + // FormTypeClass must throw. + $validator = new FormTypeClassValidator([new TestType()]); + $validator->initialize($this->executionContextMock); + + $this->expectException(InvalidArgumentException::class); + $validator->validate(TestType::class, new class extends Constraint { + }); + } + + public function test_class_not_in_form_types_raises_message_violation(): void + { + // Kills LogicalNot (line 46) and MethodCallRemoval (line 47): a real class that is not among + // the configured form types must raise exactly `message`. + $constraint = new FormTypeClass(); + $messages = $this->captureMessages(__CLASS__, $constraint); + + self::assertSame([$constraint->message], $messages); + } + + public function test_non_class_string_raises_exception_message_violation(): void + { + // Kills MethodCallRemoval (line 53): a string that is not a class makes ClassNameValidator + // throw; the catch block must raise a violation carrying the exception message. + $messages = $this->captureMessages('NotAClass', new FormTypeClass()); + + self::assertCount(1, $messages); + self::assertStringContainsString('NotAClass', $messages[0]); + } + + public function test_valid_form_type_class_raises_no_violation(): void + { + // Confirms the happy path: a configured form type must produce no violation (pins line 46 in + // the other direction). + $messages = $this->captureMessages(TestType::class, new FormTypeClass()); + + self::assertSame([], $messages); + } } diff --git a/tests/Validator/Constraints/NewEmailAddressValidatorTest.php b/tests/Validator/Constraints/NewEmailAddressValidatorTest.php index 51ea57e0..0a242ce2 100644 --- a/tests/Validator/Constraints/NewEmailAddressValidatorTest.php +++ b/tests/Validator/Constraints/NewEmailAddressValidatorTest.php @@ -188,4 +188,134 @@ public function test_no_error_if_new_email_is_unique(): void ->setNewEmailAddress('new@email.com'); $this->newEmailAddressValidator->validate($dummyUser, $constraint); } + + // --- Deterministic single-branch coverage (kills surviving mutants) --- + + /** + * Captures the messages and atPath targets of every violation the validator raises for a + * single validate() call, without relying on ordered mock expectations across branches. + * + * @return array{messages: list, paths: list} + */ + private function captureViolations(NewEmailAddressValidator $validator, AbstractUser $user, NewEmailAddress $constraint): array + { + $messages = []; + $paths = []; + + $builder = $this->createStub(ConstraintViolationBuilderInterface::class); + $builder->method('atPath')->willReturnCallback(static function (string $path) use (&$paths, $builder): ConstraintViolationBuilderInterface { + $paths[] = $path; + + return $builder; + }); + + $context = $this->createStub(ExecutionContextInterface::class); + $context->method('buildViolation')->willReturnCallback(static function (string $message) use (&$messages, $builder): ConstraintViolationBuilderInterface { + $messages[] = $message; + + return $builder; + }); + + $validator->initialize($context); + $validator->validate($user, $constraint); + + return ['messages' => $messages, 'paths' => $paths]; + } + + /** + * @param AbstractUser|null $repoResult what the repository returns for findExistingUserByNewEmail + */ + private function makeValidator(?AbstractUser $repoResult): NewEmailAddressValidator + { + $repo = $this->createStub(UserRepository::class); + $repo->method('findExistingUserByNewEmail')->willReturn($repoResult); + + return new NewEmailAddressValidator($repo); + } + + public function test_empty_new_email_returns_before_match_check(): void + { + // Kills LogicalNot (line 44) and ReturnRemoval (line 45): with an empty new email that equals + // the (empty) current address and a verified state, only the early return prevents a spurious + // "same as previous" violation. The mutant reaches line 48 ('' === '') and raises `message`. + $validator = $this->makeValidator(null); + + $user = new class extends AbstractUser { + }; + $user->setEmailAddressVerified(true); + $user->setEmailAddress('')->setNewEmailAddress(''); + + $result = $this->captureViolations($validator, $user, new NewEmailAddress()); + + self::assertSame([], $result['messages']); + } + + public function test_verified_matching_email_adds_only_the_match_message(): void + { + // Kills Identical (=== → !==), MethodCallRemoval (line 49) and ReturnRemoval (line 53). The + // repository is primed to return a user, so if the code failed to return after the match it + // would add a SECOND (uniqueMessage) violation — the exact-array assertion catches that. + $user = new class extends AbstractUser { + }; + $user->setEmailAddressVerified(true); + $user->setEmailAddress('same@example.com')->setNewEmailAddress('same@example.com'); + + $validator = $this->makeValidator($user); + + $constraint = new NewEmailAddress(); + $result = $this->captureViolations($validator, $user, $constraint); + + self::assertSame([$constraint->message], $result['messages']); + self::assertSame(['newEmailAddress'], $result['paths']); + } + + public function test_verified_but_different_email_raises_no_match_violation(): void + { + // Kills the second-operand negation and LogicalAndNegation on line 48: a DIFFERENT new email + // must not trigger the match branch, and the repository (primed null) adds nothing. + $user = new class extends AbstractUser { + }; + $user->setEmailAddressVerified(true); + $user->setEmailAddress('current@example.com')->setNewEmailAddress('changed@example.com'); + + $validator = $this->makeValidator(null); + + $result = $this->captureViolations($validator, $user, new NewEmailAddress()); + + self::assertSame([], $result['messages']); + } + + public function test_unverified_matching_email_raises_no_match_violation(): void + { + // Kills LogicalAnd (&& → ||) and the first-operand negation on line 48: when the address is + // NOT verified, an identical new email must not trigger the match violation. + $user = new class extends AbstractUser { + }; + $user->setEmailAddressVerified(false); + $user->setEmailAddress('same@example.com')->setNewEmailAddress('same@example.com'); + + $validator = $this->makeValidator(null); + + $result = $this->captureViolations($validator, $user, new NewEmailAddress()); + + self::assertSame([], $result['messages']); + } + + public function test_existing_user_with_new_email_raises_unique_message(): void + { + // Kills IfNegation (line 56) and MethodCallRemoval (line 57): when the repository finds an + // existing user for the new email, exactly `uniqueMessage` must fire on `newEmailAddress`. + $user = new class extends AbstractUser { + }; + $user->setEmailAddressVerified(false); + $user->setEmailAddress('current@example.com')->setNewEmailAddress('taken@example.com'); + + $validator = $this->makeValidator($user); + + $constraint = new NewEmailAddress(); + $result = $this->captureViolations($validator, $user, $constraint); + + self::assertSame([$constraint->uniqueMessage], $result['messages']); + self::assertSame(['newEmailAddress'], $result['paths']); + } }