Skip to content

Commit 51d6352

Browse files
committed
Restore pipeline: CacheHeadersEventListener unit tests + CS fixes
Add tests/EventListener/Api/CacheHeadersEventListenerTest.php (9 tests) to kill the mutants left by the #200 listener - Infection runs PHPUnit, not Behat, so the cache_headers.feature coverage did not register. Every branch is covered (method/resource-class guards, configured-class and subclass matches, publishable fallback, and the authenticated-token check), taking mutation score 84.45% -> 85.3%, back above the 85% pipeline minimum. No production behaviour changed. Also includes coding-standards fixes applied for the pipeline in UploadableAttributeReader and ManifestDepthGroupTrait (+ its test).
1 parent c629748 commit 51d6352

4 files changed

Lines changed: 264 additions & 15 deletions

File tree

src/AttributeReader/UploadableAttributeReader.php

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,7 @@ public function getConfiguredProperties(object|string $data, bool $skipUploadabl
102102
// same file. Fail loudly instead of corrupting data: each field needs a distinct
103103
// `property:` (with a matching nullable string entity property; the bundle maps the column).
104104
if (isset($seenStorageProperties[$config->property])) {
105-
throw new UnsupportedAnnotationException(\sprintf(
106-
'Uploadable %s has two UploadableField properties ("%s" and "%s") sharing the storage property "%s". Set a distinct `property:` on each UploadableField so uploads do not overwrite each other.',
107-
\is_string($data) ? $data : $data::class,
108-
$seenStorageProperties[$config->property],
109-
$reflectionProperty->getName(),
110-
$config->property
111-
));
105+
throw new UnsupportedAnnotationException(\sprintf('Uploadable %s has two UploadableField properties ("%s" and "%s") sharing the storage property "%s". Set a distinct `property:` on each UploadableField so uploads do not overwrite each other.', \is_string($data) ? $data : $data::class, $seenStorageProperties[$config->property], $reflectionProperty->getName(), $config->property));
112106
}
113107
$seenStorageProperties[$config->property] = $reflectionProperty->getName();
114108

src/Serializer/Normalizer/Trait/ManifestDepthGroupTrait.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ private function buildDepthGroups(array $resource): array
5050
* or already-seen (deduplicated) resource — so blank-node metadata and back-references never
5151
* appear as nodes, matching the flat behaviour this replaced.
5252
*
53-
* @param array<int, array> $parentResources collected boundary resources (by reference)
54-
* @param array<string, true> $seen per-depth IRI dedup set (by reference)
53+
* @param array<int, array> $parentResources collected boundary resources (by reference)
54+
* @param array<string, true> $seen per-depth IRI dedup set (by reference)
5555
*
5656
* @return list<array{iri: string, children: array}>
5757
*/
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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\EventListener\Api;
13+
14+
use Doctrine\Persistence\ManagerRegistry;
15+
use PHPUnit\Framework\TestCase;
16+
use Silverback\ApiComponentsBundle\Annotation\Publishable;
17+
use Silverback\ApiComponentsBundle\AttributeReader\PublishableAttributeReader;
18+
use Silverback\ApiComponentsBundle\EventListener\Api\CacheHeadersEventListener;
19+
use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker;
20+
use Symfony\Component\HttpFoundation\Request;
21+
use Symfony\Component\HttpFoundation\Response;
22+
use Symfony\Component\HttpKernel\Event\ResponseEvent;
23+
use Symfony\Component\HttpKernel\HttpKernelInterface;
24+
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
25+
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
26+
use Symfony\Component\Security\Core\User\UserInterface;
27+
28+
/**
29+
* @author Daniel West <daniel@silverback.is>
30+
*/
31+
class CacheHeadersEventListenerTest extends TestCase
32+
{
33+
private TokenStorageInterface $tokenStorage;
34+
35+
protected function setUp(): void
36+
{
37+
$this->tokenStorage = $this->createStub(TokenStorageInterface::class);
38+
}
39+
40+
public function test_authenticated_request_for_configured_resource_is_marked_private_no_store(): void
41+
{
42+
$this->authenticateAsUser();
43+
$response = $this->dispatch(
44+
resourceClass: CacheHeadersConfiguredResource::class,
45+
method: Request::METHOD_GET,
46+
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
47+
initialSharedMaxAge: 3600,
48+
);
49+
50+
self::assertTrue($response->headers->hasCacheControlDirective('private'));
51+
self::assertFalse($response->headers->hasCacheControlDirective('public'));
52+
self::assertTrue($response->headers->getCacheControlDirective('no-store'));
53+
self::assertFalse($response->headers->hasCacheControlDirective('s-maxage'));
54+
}
55+
56+
public function test_authenticated_request_for_subclass_of_configured_resource_is_marked_private(): void
57+
{
58+
// is_a(..., true) must match subclasses; a FalseValue mutant on the third arg breaks this.
59+
$this->authenticateAsUser();
60+
$response = $this->dispatch(
61+
resourceClass: CacheHeadersConfiguredChildResource::class,
62+
method: Request::METHOD_GET,
63+
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
64+
);
65+
66+
self::assertTrue($response->headers->hasCacheControlDirective('private'));
67+
self::assertTrue($response->headers->getCacheControlDirective('no-store'));
68+
}
69+
70+
public function test_authenticated_request_for_publishable_resource_is_marked_private(): void
71+
{
72+
// Not in the configured list — matched via the PublishableAttributeReader fallback only.
73+
$this->authenticateAsUser();
74+
$response = $this->dispatch(
75+
resourceClass: CacheHeadersPublishableResource::class,
76+
method: Request::METHOD_GET,
77+
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
78+
);
79+
80+
self::assertTrue($response->headers->hasCacheControlDirective('private'));
81+
self::assertTrue($response->headers->getCacheControlDirective('no-store'));
82+
}
83+
84+
public function test_authenticated_request_for_unaffected_resource_keeps_public_cache(): void
85+
{
86+
$this->authenticateAsUser();
87+
$response = $this->dispatch(
88+
resourceClass: CacheHeadersUnaffectedResource::class,
89+
method: Request::METHOD_GET,
90+
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
91+
initialSharedMaxAge: 3600,
92+
);
93+
94+
self::assertFalse($response->headers->hasCacheControlDirective('private'));
95+
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
96+
self::assertTrue($response->headers->hasCacheControlDirective('s-maxage'));
97+
}
98+
99+
public function test_non_cacheable_method_is_left_untouched_even_for_configured_resource(): void
100+
{
101+
$this->authenticateAsUser();
102+
$response = $this->dispatch(
103+
resourceClass: CacheHeadersConfiguredResource::class,
104+
method: Request::METHOD_POST,
105+
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
106+
initialSharedMaxAge: 3600,
107+
);
108+
109+
self::assertFalse($response->headers->hasCacheControlDirective('private'));
110+
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
111+
self::assertTrue($response->headers->hasCacheControlDirective('s-maxage'));
112+
}
113+
114+
public function test_missing_resource_class_is_left_untouched(): void
115+
{
116+
// The token storage must never be consulted once the resource class check bails out.
117+
$tokenStorage = $this->createMock(TokenStorageInterface::class);
118+
$tokenStorage->expects(self::never())->method('getToken');
119+
$this->tokenStorage = $tokenStorage;
120+
$response = $this->dispatch(
121+
resourceClass: null,
122+
method: Request::METHOD_GET,
123+
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
124+
initialSharedMaxAge: 3600,
125+
);
126+
127+
self::assertFalse($response->headers->hasCacheControlDirective('private'));
128+
self::assertTrue($response->headers->hasCacheControlDirective('s-maxage'));
129+
}
130+
131+
public function test_non_string_resource_class_is_left_untouched(): void
132+
{
133+
$response = $this->dispatch(
134+
resourceClass: ['not', 'a', 'string'],
135+
method: Request::METHOD_GET,
136+
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
137+
initialSharedMaxAge: 3600,
138+
);
139+
140+
self::assertFalse($response->headers->hasCacheControlDirective('private'));
141+
self::assertTrue($response->headers->hasCacheControlDirective('s-maxage'));
142+
}
143+
144+
public function test_anonymous_request_for_configured_resource_keeps_public_cache(): void
145+
{
146+
$this->tokenStorage->method('getToken')->willReturn(null);
147+
$response = $this->dispatch(
148+
resourceClass: CacheHeadersConfiguredResource::class,
149+
method: Request::METHOD_GET,
150+
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
151+
initialSharedMaxAge: 3600,
152+
);
153+
154+
self::assertFalse($response->headers->hasCacheControlDirective('private'));
155+
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
156+
self::assertTrue($response->headers->hasCacheControlDirective('s-maxage'));
157+
}
158+
159+
public function test_token_without_user_interface_keeps_public_cache(): void
160+
{
161+
// A token exists but its user is not a UserInterface (e.g. a string 'anon.'); the resource
162+
// must stay public. Kills the LogicalAnd->LogicalOr and instanceof mutants in isAuthenticated().
163+
$token = $this->createStub(TokenInterface::class);
164+
$token->method('getUser')->willReturn(null);
165+
$this->tokenStorage->method('getToken')->willReturn($token);
166+
167+
$response = $this->dispatch(
168+
resourceClass: CacheHeadersConfiguredResource::class,
169+
method: Request::METHOD_GET,
170+
personalisedResourceClasses: [CacheHeadersConfiguredResource::class],
171+
initialSharedMaxAge: 3600,
172+
);
173+
174+
self::assertFalse($response->headers->hasCacheControlDirective('private'));
175+
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
176+
self::assertTrue($response->headers->hasCacheControlDirective('s-maxage'));
177+
}
178+
179+
private function authenticateAsUser(): void
180+
{
181+
$token = $this->createStub(TokenInterface::class);
182+
$token->method('getUser')->willReturn($this->createStub(UserInterface::class));
183+
$this->tokenStorage->method('getToken')->willReturn($token);
184+
}
185+
186+
/**
187+
* @param array<class-string> $personalisedResourceClasses
188+
*/
189+
private function dispatch(
190+
mixed $resourceClass,
191+
string $method,
192+
array $personalisedResourceClasses,
193+
?int $initialSharedMaxAge = null,
194+
): Response {
195+
$publishableReader = new PublishableAttributeReader($this->createStub(ManagerRegistry::class));
196+
$statusChecker = $this->createStub(PublishableStatusChecker::class);
197+
$statusChecker->method('getAttributeReader')->willReturn($publishableReader);
198+
199+
$listener = new CacheHeadersEventListener(
200+
$this->tokenStorage,
201+
$statusChecker,
202+
$personalisedResourceClasses,
203+
);
204+
205+
$request = new Request();
206+
$request->setMethod($method);
207+
if (null !== $resourceClass) {
208+
$request->attributes->set('_api_resource_class', $resourceClass);
209+
}
210+
211+
$response = new Response();
212+
$response->setPublic();
213+
if (null !== $initialSharedMaxAge) {
214+
$response->setSharedMaxAge($initialSharedMaxAge);
215+
}
216+
217+
$event = new ResponseEvent(
218+
$this->createStub(HttpKernelInterface::class),
219+
$request,
220+
HttpKernelInterface::MAIN_REQUEST,
221+
$response,
222+
);
223+
224+
$listener->onPostRespond($event);
225+
226+
return $event->getResponse();
227+
}
228+
}
229+
230+
#[Publishable]
231+
class CacheHeadersPublishableResource
232+
{
233+
}
234+
235+
class CacheHeadersConfiguredResource
236+
{
237+
}
238+
239+
class CacheHeadersConfiguredChildResource extends CacheHeadersConfiguredResource
240+
{
241+
}
242+
243+
class CacheHeadersUnaffectedResource
244+
{
245+
}

tests/Serializer/Normalizer/ManifestDepthGroupTraitTest.php

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,22 @@ public function test_containment_is_preserved_as_nesting(): void
192192
];
193193

194194
$this->assertSame(
195-
[$this->n('/_/routes/home',
196-
$this->n('/page_data/pd1',
197-
$this->n('/_/pages/p1',
198-
$this->n('/_/component_groups/cg1',
199-
$this->n('/_/component_positions/cp1',
200-
$this->n('/component/dummy/c1')))))), ],
195+
[$this->n(
196+
'/_/routes/home',
197+
$this->n(
198+
'/page_data/pd1',
199+
$this->n(
200+
'/_/pages/p1',
201+
$this->n(
202+
'/_/component_groups/cg1',
203+
$this->n(
204+
'/_/component_positions/cp1',
205+
$this->n('/component/dummy/c1')
206+
)
207+
)
208+
)
209+
)
210+
), ],
201211
$this->subject->groups($resource)
202212
);
203213
}

0 commit comments

Comments
 (0)