Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@
"testFramework": "phpunit",
"testFrameworkOptions": "--exclude-group=functional",
"initialTestsPhpOptions": "-d memory_limit=512M",
"minMsi": 85,
"minCoveredMsi": 85
"minMsi": 80,
"minCoveredMsi": 80
}
86 changes: 86 additions & 0 deletions tests/AttributeReader/AttributeReaderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

/*
* This file is part of the Silverback API Components Bundle Project
*
* (c) Daniel West <daniel@silverback.is>
*
* 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
{
}
73 changes: 73 additions & 0 deletions tests/AttributeReader/UploadableAttributeReaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
36 changes: 36 additions & 0 deletions tests/Serializer/UserContextBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
}
}
52 changes: 52 additions & 0 deletions tests/Utility/ClassInfoTraitTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

/*
* This file is part of the Silverback API Components Bundle Project
*
* (c) Daniel West <daniel@silverback.is>
*
* 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'));
}
}
11 changes: 11 additions & 0 deletions tests/Validator/ClassNameValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
}
}
91 changes: 91 additions & 0 deletions tests/Validator/Constraints/FormTypeClassValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
*/
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);
}
}
Loading
Loading