Skip to content

Commit 27861ac

Browse files
Merge pull request #203 from components-web-app/chore/mutation-gate-align-80
Align Infection MSI gate to 80 + strengthen mutation coverage
2 parents b99c1d8 + 349b5b7 commit 27861ac

8 files changed

Lines changed: 481 additions & 2 deletions

infection.json5

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@
1717
"testFramework": "phpunit",
1818
"testFrameworkOptions": "--exclude-group=functional",
1919
"initialTestsPhpOptions": "-d memory_limit=512M",
20-
"minMsi": 85,
21-
"minCoveredMsi": 85
20+
"minMsi": 80,
21+
"minCoveredMsi": 80
2222
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\Tests\AttributeReader;
13+
14+
use Doctrine\Persistence\ManagerRegistry;
15+
use PHPUnit\Framework\TestCase;
16+
use Silverback\ApiComponentsBundle\Annotation\Publishable;
17+
use Silverback\ApiComponentsBundle\AttributeReader\PublishableAttributeReader;
18+
19+
/**
20+
* Exercises the shared traversal logic in the abstract AttributeReader through the concrete
21+
* PublishableAttributeReader (isConfigured resolves via reflection only).
22+
*/
23+
class AttributeReaderTest extends TestCase
24+
{
25+
private function buildReader(): PublishableAttributeReader
26+
{
27+
return new PublishableAttributeReader($this->createStub(ManagerRegistry::class));
28+
}
29+
30+
public function test_attribute_declared_directly_on_class_is_found(): void
31+
{
32+
self::assertTrue($this->buildReader()->isConfigured(DirectlyPublishableStub::class));
33+
}
34+
35+
public function test_attribute_declared_on_grandparent_is_found(): void
36+
{
37+
// Kills While_ (line 121) and LogicalNot (line 123): the parent-class walk must climb past the
38+
// intermediate class (no attribute) up to the grandparent that carries it. A broken loop or an
39+
// un-negated condition stops after the first parent and reports "not configured".
40+
self::assertTrue($this->buildReader()->isConfigured(GrandchildOfPublishableStub::class));
41+
}
42+
43+
public function test_attribute_declared_on_trait_is_found(): void
44+
{
45+
// Kills Foreach_ (line 139): the trait walk must iterate the class's traits to find the one
46+
// carrying the attribute.
47+
self::assertTrue($this->buildReader()->isConfigured(UsesPublishableTraitStub::class));
48+
}
49+
50+
public function test_class_without_attribute_anywhere_is_not_configured(): void
51+
{
52+
self::assertFalse($this->buildReader()->isConfigured(NoAttributeAnywhereStub::class));
53+
}
54+
}
55+
56+
#[Publishable]
57+
class DirectlyPublishableStub
58+
{
59+
}
60+
61+
#[Publishable]
62+
class PublishableAncestorStub
63+
{
64+
}
65+
66+
class IntermediateNoAttributeStub extends PublishableAncestorStub
67+
{
68+
}
69+
70+
class GrandchildOfPublishableStub extends IntermediateNoAttributeStub
71+
{
72+
}
73+
74+
#[Publishable]
75+
trait PublishableMarkerTrait
76+
{
77+
}
78+
79+
class UsesPublishableTraitStub
80+
{
81+
use PublishableMarkerTrait;
82+
}
83+
84+
class NoAttributeAnywhereStub
85+
{
86+
}

tests/AttributeReader/UploadableAttributeReaderTest.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,79 @@ public function test_fields_with_distinct_storage_properties_are_returned(): voi
4949
self::assertSame('filename', $configured['file']->property);
5050
self::assertSame('previewFilename', $configured['preview']->property);
5151
}
52+
53+
private function buildReaderWithoutImagine(): UploadableAttributeReader
54+
{
55+
return new UploadableAttributeReader($this->createStub(ManagerRegistry::class), false);
56+
}
57+
58+
public function test_default_skip_check_false_rejects_non_uploadable_class(): void
59+
{
60+
// Kills FalseValue (line 84 default arg) and the LogicalNot/LogicalAnd guards (line 86): with
61+
// the default $skipUploadableCheck the Uploadable check must run, so a non-uploadable class
62+
// fails with the "is it not configured as Uploadable" message (not the later "No field
63+
// configurations" message a skipped check would produce).
64+
$reader = $this->buildReader();
65+
66+
$this->expectException(UnsupportedAnnotationException::class);
67+
$this->expectExceptionMessage('is it not configured as Uploadable');
68+
69+
iterator_to_array($reader->getConfiguredProperties(PlainNonUploadableFixture::class));
70+
}
71+
72+
public function test_uploadable_class_without_fields_throws_no_field_configurations(): void
73+
{
74+
// Kills FalseValue (line 90, $found = false): an Uploadable class with no UploadableField must
75+
// throw "No field configurations". If $found started true the guard would be skipped and the
76+
// generator would complete silently.
77+
$reader = $this->buildReader();
78+
79+
$this->expectException(UnsupportedAnnotationException::class);
80+
$this->expectExceptionMessage('No field configurations');
81+
82+
iterator_to_array($reader->getConfiguredProperties(EmptyUploadableFixture::class, true));
83+
}
84+
85+
public function test_imagine_filters_without_bundle_throws(): void
86+
{
87+
// Kills the LogicalNot / NotIdentical / LogicalAnd chain on line 74: with the Imagine bundle
88+
// disabled, a field declaring imagineFilters must be rejected.
89+
$reader = $this->buildReaderWithoutImagine();
90+
$property = new \ReflectionProperty(ImagineFilterUploadableFixture::class, 'file');
91+
92+
$this->expectException(\Silverback\ApiComponentsBundle\Exception\BadMethodCallException::class);
93+
$reader->getPropertyConfiguration($property);
94+
}
95+
96+
public function test_field_without_imagine_filters_is_allowed_when_bundle_disabled(): void
97+
{
98+
// Kills the LogicalOr-direction mutants on line 74: a field with no imagineFilters must be
99+
// returned even when the Imagine bundle is disabled (the guard must NOT fire).
100+
$reader = $this->buildReaderWithoutImagine();
101+
$property = new \ReflectionProperty(ValidMultiUploadableFixture::class, 'file');
102+
103+
$config = $reader->getPropertyConfiguration($property);
104+
105+
self::assertSame('filename', $config->property);
106+
}
107+
}
108+
109+
class PlainNonUploadableFixture
110+
{
111+
public ?File $file = null;
112+
}
113+
114+
#[Uploadable]
115+
class EmptyUploadableFixture
116+
{
117+
public ?string $name = null;
118+
}
119+
120+
#[Uploadable]
121+
class ImagineFilterUploadableFixture
122+
{
123+
#[UploadableField(adapter: 'local', imagineFilters: ['thumbnail'])]
124+
public ?File $file = null;
52125
}
53126

54127
#[Uploadable]

tests/Serializer/UserContextBuilderTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,40 @@ public function test_request_input_with_super_admin_groups(): void
149149

150150
$this->assertEquals(['groups' => ['User:input', 'User:superAdmin'], 'resource_class' => User::class], $this->userContextBuilder->createFromRequest($request, $normalization, null));
151151
}
152+
153+
public function test_existing_array_groups_are_preserved_not_reset(): void
154+
{
155+
// Kills LogicalAndAllSubExprNegation (line 39): when `groups` IS a configured array, the
156+
// negated mutant would treat it as unconfigured and reset it to [], dropping 'existing_group'.
157+
// The exact-array assertion is the killer (no mock expectations).
158+
$this->serializerContextBuilderMock
159+
->method('createFromRequest')
160+
->willReturn(['groups' => ['existing_group'], 'resource_class' => User::class]);
161+
162+
$this->authorizationCheckerMock
163+
->method('isGranted')
164+
->willReturn(false);
165+
166+
$result = $this->userContextBuilder->createFromRequest(new Request(), true, null);
167+
168+
self::assertSame(['existing_group', 'User:output'], $result['groups']);
169+
}
170+
171+
public function test_non_array_groups_are_reset_to_empty_before_appending(): void
172+
{
173+
// Kills LogicalAnd (line 39, && → ||): when `groups` is set but NOT an array, the correct code
174+
// treats it as unconfigured and resets to []. The `||` mutant would instead keep it configured
175+
// and attempt to append to a string. The exact-array assertion is the killer.
176+
$this->serializerContextBuilderMock
177+
->method('createFromRequest')
178+
->willReturn(['groups' => 'not_an_array', 'resource_class' => User::class]);
179+
180+
$this->authorizationCheckerMock
181+
->method('isGranted')
182+
->willReturn(false);
183+
184+
$result = $this->userContextBuilder->createFromRequest(new Request(), true, null);
185+
186+
self::assertSame(['User:output'], $result['groups']);
187+
}
152188
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\Tests\Utility;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Silverback\ApiComponentsBundle\Utility\ClassInfoTrait;
16+
17+
class ClassInfoTraitTest extends TestCase
18+
{
19+
private object $subject;
20+
21+
protected function setUp(): void
22+
{
23+
$this->subject = new class {
24+
use ClassInfoTrait;
25+
26+
public function real(string $className): string
27+
{
28+
return $this->getRealClassName($className);
29+
}
30+
};
31+
}
32+
33+
public function test_plain_class_name_is_returned_unchanged(): void
34+
{
35+
self::assertSame('App\\Entity\\Foo', $this->subject->real('App\\Entity\\Foo'));
36+
}
37+
38+
public function test_doctrine_cg_proxy_marker_is_stripped(): void
39+
{
40+
// Kills LogicalAnd (line 41): with a '__CG__' marker present, `false === $positionCg` is false,
41+
// so the early "return unchanged" must NOT fire — the real class name is extracted instead. The
42+
// `||` mutant would return the proxy name unchanged.
43+
self::assertSame('App\\Entity\\Foo', $this->subject->real('Proxies\\__CG__\\App\\Entity\\Foo'));
44+
}
45+
46+
public function test_ocramius_pm_proxy_marker_is_stripped(): void
47+
{
48+
// Exercises the '__PM__' branch: the real class name sits between the marker and the trailing
49+
// proxy-id segment.
50+
self::assertSame('App\\Entity\\Foo', $this->subject->real('MyProxies\\__PM__\\App\\Entity\\Foo\\abc123'));
51+
}
52+
}

tests/Validator/ClassNameValidatorTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,15 @@ public function test_class_same_validation_invalid_classname(): void
6565
$this->expectException(InvalidArgumentException::class);
6666
ClassNameValidator::isClassSame('NotAClass', $this->class);
6767
}
68+
69+
/**
70+
* Kills FalseValue (line 32): when no candidate matches, validate() must return false. A mutant
71+
* flipping the fall-through to `true` would make every unrelated class validate as a form type.
72+
*
73+
* @throws \ReflectionException
74+
*/
75+
public function test_validate_returns_false_when_no_candidate_matches(): void
76+
{
77+
$this->assertFalse(ClassNameValidator::validate(User::class, [$this->class]));
78+
}
6879
}

tests/Validator/Constraints/FormTypeClassValidatorTest.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,4 +186,95 @@ public function test_form_type_class_options_passed_to_parent(): void
186186
$constraint = new FormTypeClass(message: 'different message option');
187187
$this->assertEquals('different message option', $constraint->message);
188188
}
189+
190+
// --- Deterministic single-branch coverage (kills surviving mutants) ---
191+
192+
/**
193+
* Captures the messages of every violation raised for a single validate() call.
194+
*
195+
* @return list<string>
196+
*/
197+
private function captureMessages(mixed $value, Constraint $constraint, iterable $formTypes = [new TestType()]): array
198+
{
199+
$validator = new FormTypeClassValidator($formTypes);
200+
201+
$messages = [];
202+
$builder = $this->createStub(ConstraintViolationBuilderInterface::class);
203+
$builder->method('setParameter')->willReturn($builder);
204+
$builder->method('atPath')->willReturn($builder);
205+
206+
$context = $this->createStub(ExecutionContextInterface::class);
207+
$context->method('buildViolation')->willReturnCallback(static function (string $message) use (&$messages, $builder): ConstraintViolationBuilderInterface {
208+
$messages[] = $message;
209+
210+
return $builder;
211+
});
212+
213+
$validator->initialize($context);
214+
$validator->validate($value, $constraint);
215+
216+
return $messages;
217+
}
218+
219+
public function test_empty_string_value_raises_no_violation(): void
220+
{
221+
// Kills LogicalNot (line 34) and ReturnRemoval (line 35): an empty string is falsy and must
222+
// return immediately. Without the early return it reaches ClassNameValidator, which throws on
223+
// the non-existent class '' and produces a spurious violation.
224+
$messages = $this->captureMessages('', new FormTypeClass());
225+
226+
self::assertSame([], $messages);
227+
}
228+
229+
public function test_non_string_value_throws_invalid_argument(): void
230+
{
231+
// Kills LogicalNot (line 37) and Throw_ (line 38): a non-string value must throw before any
232+
// validation runs.
233+
$validator = new FormTypeClassValidator([new TestType()]);
234+
$validator->initialize($this->executionContextMock);
235+
236+
$this->expectException(InvalidArgumentException::class);
237+
$validator->validate(new TestType(), new FormTypeClass());
238+
}
239+
240+
public function test_unexpected_constraint_type_throws_invalid_argument(): void
241+
{
242+
// Kills InstanceOf_ / LogicalNot (line 40) and Throw_ (line 41): a constraint that is not a
243+
// FormTypeClass must throw.
244+
$validator = new FormTypeClassValidator([new TestType()]);
245+
$validator->initialize($this->executionContextMock);
246+
247+
$this->expectException(InvalidArgumentException::class);
248+
$validator->validate(TestType::class, new class extends Constraint {
249+
});
250+
}
251+
252+
public function test_class_not_in_form_types_raises_message_violation(): void
253+
{
254+
// Kills LogicalNot (line 46) and MethodCallRemoval (line 47): a real class that is not among
255+
// the configured form types must raise exactly `message`.
256+
$constraint = new FormTypeClass();
257+
$messages = $this->captureMessages(__CLASS__, $constraint);
258+
259+
self::assertSame([$constraint->message], $messages);
260+
}
261+
262+
public function test_non_class_string_raises_exception_message_violation(): void
263+
{
264+
// Kills MethodCallRemoval (line 53): a string that is not a class makes ClassNameValidator
265+
// throw; the catch block must raise a violation carrying the exception message.
266+
$messages = $this->captureMessages('NotAClass', new FormTypeClass());
267+
268+
self::assertCount(1, $messages);
269+
self::assertStringContainsString('NotAClass', $messages[0]);
270+
}
271+
272+
public function test_valid_form_type_class_raises_no_violation(): void
273+
{
274+
// Confirms the happy path: a configured form type must produce no violation (pins line 46 in
275+
// the other direction).
276+
$messages = $this->captureMessages(TestType::class, new FormTypeClass());
277+
278+
self::assertSame([], $messages);
279+
}
189280
}

0 commit comments

Comments
 (0)