Skip to content

Commit 1c3b32b

Browse files
committed
Add unit tests for the previously uncovered hot path
Cover the five highest-value gaps surfaced by the audit: - CheckSourceAction: blank source, no match, self-match by excludeId, and the full-conflict JSON payload. - RemovableRedirectFinder: empty resolver result, single-channel hit, multi-channel dedup of repeated matches, and distinct-per-channel matches; pins the iterable contract via iterator_to_array. - RedirectionPathResolver: empty path, multi-step chain walking, the only404 short-circuit on the do-while, channel + only404 forwarding, resolveFromRequest delegation, and InfiniteLoopException on cycle detection. - Pruner: the prune(<= 0) no-op branch with a shouldNotBeCalled() proof on the registry. The actual-prune branch hits a real QueryBuilder + SimpleBatchIteratorAggregate and belongs in functional tests. - RequestSubscriber (covers AbstractRedirectSubscriber end-to-end): subscribed-events tuple, sub-request bypass, empty-path no-op, swallowed ChannelNotFoundException, channel forwarded to resolver, permanent (301) vs temporary (302) responses, query-string preservation, infinite-loop log + skip, and per-redirect markAsAccessed() + manager flush. Unit suite goes from 66 to 90 tests; PHPStan and ECS still clean.
1 parent ff733c2 commit 1c3b32b

5 files changed

Lines changed: 616 additions & 0 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Setono\SyliusRedirectPlugin\Unit\Controller\Admin;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Prophecy\PhpUnit\ProphecyTrait;
9+
use Setono\SyliusRedirectPlugin\Controller\Admin\CheckSourceAction;
10+
use Setono\SyliusRedirectPlugin\Model\RedirectInterface;
11+
use Setono\SyliusRedirectPlugin\Repository\RedirectRepositoryInterface;
12+
use Symfony\Component\HttpFoundation\Request;
13+
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
14+
15+
final class CheckSourceActionTest extends TestCase
16+
{
17+
use ProphecyTrait;
18+
19+
public function test_it_reports_no_match_when_source_is_blank(): void
20+
{
21+
$repository = $this->prophesize(RedirectRepositoryInterface::class);
22+
$repository->findOneBySource(\Prophecy\Argument::any())->shouldNotBeCalled();
23+
24+
$action = new CheckSourceAction(
25+
$repository->reveal(),
26+
$this->prophesize(UrlGeneratorInterface::class)->reveal(),
27+
);
28+
29+
$response = $action(new Request(['source' => ' ']));
30+
31+
self::assertSame('{"exists":false}', $response->getContent());
32+
}
33+
34+
public function test_it_reports_no_match_when_repository_returns_null(): void
35+
{
36+
$repository = $this->prophesize(RedirectRepositoryInterface::class);
37+
$repository->findOneBySource('/foo')->willReturn(null);
38+
39+
$action = new CheckSourceAction(
40+
$repository->reveal(),
41+
$this->prophesize(UrlGeneratorInterface::class)->reveal(),
42+
);
43+
44+
$response = $action(new Request(['source' => '/foo']));
45+
46+
self::assertSame('{"exists":false}', $response->getContent());
47+
}
48+
49+
public function test_it_reports_no_match_when_the_only_match_is_excluded_by_id(): void
50+
{
51+
$existing = $this->prophesize(RedirectInterface::class);
52+
$existing->getId()->willReturn(7);
53+
54+
$repository = $this->prophesize(RedirectRepositoryInterface::class);
55+
$repository->findOneBySource('/foo')->willReturn($existing->reveal());
56+
57+
$action = new CheckSourceAction(
58+
$repository->reveal(),
59+
$this->prophesize(UrlGeneratorInterface::class)->reveal(),
60+
);
61+
62+
$response = $action(new Request(['source' => '/foo', 'excludeId' => '7']));
63+
64+
self::assertSame('{"exists":false}', $response->getContent());
65+
}
66+
67+
public function test_it_reports_a_conflict_with_full_payload(): void
68+
{
69+
$existing = $this->prophesize(RedirectInterface::class);
70+
$existing->getId()->willReturn(42);
71+
$existing->getSource()->willReturn('/foo');
72+
$existing->getDestination()->willReturn('/bar');
73+
74+
$repository = $this->prophesize(RedirectRepositoryInterface::class);
75+
$repository->findOneBySource('/foo')->willReturn($existing->reveal());
76+
77+
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
78+
$urlGenerator
79+
->generate('setono_sylius_redirect_admin_redirect_update', ['id' => 42])
80+
->willReturn('/admin/redirects/42/edit');
81+
82+
$action = new CheckSourceAction($repository->reveal(), $urlGenerator->reveal());
83+
84+
$response = $action(new Request(['source' => '/foo']));
85+
86+
self::assertSame(
87+
'{"exists":true,"id":42,"source":"\/foo","destination":"\/bar","editUrl":"\/admin\/redirects\/42\/edit"}',
88+
$response->getContent(),
89+
);
90+
}
91+
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Setono\SyliusRedirectPlugin\Unit\EventSubscriber;
6+
7+
use Doctrine\ORM\EntityManagerInterface;
8+
use Doctrine\Persistence\ManagerRegistry;
9+
use PHPUnit\Framework\TestCase;
10+
use Prophecy\Argument;
11+
use Prophecy\PhpUnit\ProphecyTrait;
12+
use Prophecy\Prophecy\ObjectProphecy;
13+
use Psr\Log\LoggerInterface;
14+
use Setono\SyliusRedirectPlugin\EventSubscriber\RequestSubscriber;
15+
use Setono\SyliusRedirectPlugin\Model\Redirect;
16+
use Setono\SyliusRedirectPlugin\Model\RedirectInterface;
17+
use Setono\SyliusRedirectPlugin\Model\RedirectionPath;
18+
use Setono\SyliusRedirectPlugin\Resolver\RedirectionPathResolverInterface;
19+
use Sylius\Component\Channel\Context\ChannelContextInterface;
20+
use Sylius\Component\Channel\Context\ChannelNotFoundException;
21+
use Sylius\Component\Channel\Model\Channel;
22+
use Symfony\Component\HttpFoundation\RedirectResponse;
23+
use Symfony\Component\HttpFoundation\Request;
24+
use Symfony\Component\HttpFoundation\Response;
25+
use Symfony\Component\HttpKernel\Event\RequestEvent;
26+
use Symfony\Component\HttpKernel\HttpKernelInterface;
27+
28+
final class RequestSubscriberTest extends TestCase
29+
{
30+
use ProphecyTrait;
31+
32+
/** @var ObjectProphecy<ManagerRegistry> */
33+
private ObjectProphecy $managerRegistry;
34+
35+
/** @var ObjectProphecy<ChannelContextInterface> */
36+
private ObjectProphecy $channelContext;
37+
38+
/** @var ObjectProphecy<RedirectionPathResolverInterface> */
39+
private ObjectProphecy $resolver;
40+
41+
protected function setUp(): void
42+
{
43+
$this->managerRegistry = $this->prophesize(ManagerRegistry::class);
44+
$this->channelContext = $this->prophesize(ChannelContextInterface::class);
45+
$this->resolver = $this->prophesize(RedirectionPathResolverInterface::class);
46+
}
47+
48+
public function test_subscribes_to_kernel_request_at_priority_31(): void
49+
{
50+
self::assertSame(
51+
['kernel.request' => ['onKernelRequest', 31]],
52+
RequestSubscriber::getSubscribedEvents(),
53+
);
54+
}
55+
56+
public function test_it_does_nothing_for_sub_requests(): void
57+
{
58+
$this->resolver->resolveFromRequest(Argument::cetera())->shouldNotBeCalled();
59+
60+
$event = $this->createEvent(Request::create('/foo'), HttpKernelInterface::SUB_REQUEST);
61+
62+
$this->createSubscriber()->onKernelRequest($event);
63+
64+
self::assertNull($event->getResponse());
65+
}
66+
67+
public function test_it_does_nothing_when_no_redirect_matches(): void
68+
{
69+
$this->channelContext->getChannel()->willThrow(new ChannelNotFoundException());
70+
$this->resolver
71+
->resolveFromRequest(Argument::type(Request::class), null, false)
72+
->willReturn(new RedirectionPath());
73+
74+
$event = $this->createEvent(Request::create('/foo'));
75+
76+
$this->createSubscriber()->onKernelRequest($event);
77+
78+
self::assertNull($event->getResponse());
79+
}
80+
81+
public function test_it_swallows_a_channel_not_found_exception_and_resolves_anyway(): void
82+
{
83+
$this->channelContext->getChannel()->willThrow(new ChannelNotFoundException());
84+
$this->resolver
85+
->resolveFromRequest(Argument::type(Request::class), null, false)
86+
->willReturn(new RedirectionPath())
87+
->shouldBeCalled();
88+
89+
$event = $this->createEvent(Request::create('/foo'));
90+
91+
$this->createSubscriber()->onKernelRequest($event);
92+
93+
self::assertNull($event->getResponse());
94+
}
95+
96+
public function test_it_passes_the_resolved_channel_to_the_resolver(): void
97+
{
98+
$channel = new Channel();
99+
$this->channelContext->getChannel()->willReturn($channel);
100+
$this->resolver
101+
->resolveFromRequest(Argument::type(Request::class), $channel, false)
102+
->willReturn(new RedirectionPath())
103+
->shouldBeCalled();
104+
105+
$event = $this->createEvent(Request::create('/foo'));
106+
107+
$this->createSubscriber()->onKernelRequest($event);
108+
}
109+
110+
public function test_it_returns_a_permanent_redirect_for_a_non_empty_path(): void
111+
{
112+
$redirect = $this->createRedirect('/dest', permanent: true, keepQueryString: false);
113+
$path = new RedirectionPath();
114+
$path->addRedirect($redirect);
115+
116+
$this->channelContext->getChannel()->willThrow(new ChannelNotFoundException());
117+
$this->resolver->resolveFromRequest(Argument::cetera())->willReturn($path);
118+
119+
$manager = $this->prophesize(EntityManagerInterface::class);
120+
$manager->flush()->shouldBeCalled();
121+
$this->managerRegistry->getManagerForClass(Redirect::class)->willReturn($manager->reveal());
122+
123+
$event = $this->createEvent(Request::create('/source'));
124+
125+
$this->createSubscriber()->onKernelRequest($event);
126+
127+
$response = $event->getResponse();
128+
self::assertInstanceOf(RedirectResponse::class, $response);
129+
self::assertSame('/dest', $response->getTargetUrl());
130+
self::assertSame(Response::HTTP_MOVED_PERMANENTLY, $response->getStatusCode());
131+
}
132+
133+
public function test_it_returns_a_temporary_redirect_when_the_redirect_is_not_permanent(): void
134+
{
135+
$redirect = $this->createRedirect('/dest', permanent: false, keepQueryString: false);
136+
$path = new RedirectionPath();
137+
$path->addRedirect($redirect);
138+
139+
$this->channelContext->getChannel()->willThrow(new ChannelNotFoundException());
140+
$this->resolver->resolveFromRequest(Argument::cetera())->willReturn($path);
141+
142+
$manager = $this->prophesize(EntityManagerInterface::class);
143+
$this->managerRegistry->getManagerForClass(Redirect::class)->willReturn($manager->reveal());
144+
145+
$event = $this->createEvent(Request::create('/source'));
146+
147+
$this->createSubscriber()->onKernelRequest($event);
148+
149+
$response = $event->getResponse();
150+
self::assertInstanceOf(RedirectResponse::class, $response);
151+
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
152+
}
153+
154+
public function test_it_appends_the_query_string_when_keep_query_string_is_true(): void
155+
{
156+
$redirect = $this->createRedirect('/dest', permanent: true, keepQueryString: true);
157+
$path = new RedirectionPath();
158+
$path->addRedirect($redirect);
159+
160+
$this->channelContext->getChannel()->willThrow(new ChannelNotFoundException());
161+
$this->resolver->resolveFromRequest(Argument::cetera())->willReturn($path);
162+
163+
$manager = $this->prophesize(EntityManagerInterface::class);
164+
$this->managerRegistry->getManagerForClass(Redirect::class)->willReturn($manager->reveal());
165+
166+
$event = $this->createEvent(Request::create('/source?utm_source=foo&page=2'));
167+
168+
$this->createSubscriber()->onKernelRequest($event);
169+
170+
$response = $event->getResponse();
171+
self::assertInstanceOf(RedirectResponse::class, $response);
172+
self::assertStringContainsString('utm_source=foo', $response->getTargetUrl());
173+
self::assertStringContainsString('page=2', $response->getTargetUrl());
174+
}
175+
176+
public function test_it_logs_and_skips_when_the_destination_loops_back_to_the_request_path(): void
177+
{
178+
$redirect = $this->createRedirect('/source', permanent: true, keepQueryString: false);
179+
$path = new RedirectionPath();
180+
$path->addRedirect($redirect);
181+
182+
$this->channelContext->getChannel()->willThrow(new ChannelNotFoundException());
183+
$this->resolver->resolveFromRequest(Argument::cetera())->willReturn($path);
184+
185+
$manager = $this->prophesize(EntityManagerInterface::class);
186+
$this->managerRegistry->getManagerForClass(Redirect::class)->willReturn($manager->reveal());
187+
188+
$logger = $this->prophesize(LoggerInterface::class);
189+
$logger->error('Infinite loop detected', Argument::type('array'))->shouldBeCalled();
190+
191+
$subscriber = $this->createSubscriber();
192+
$subscriber->setLogger($logger->reveal());
193+
194+
$event = $this->createEvent(Request::create('/source'));
195+
196+
$subscriber->onKernelRequest($event);
197+
198+
self::assertNull($event->getResponse());
199+
}
200+
201+
public function test_it_marks_every_redirect_in_the_path_as_accessed(): void
202+
{
203+
$first = $this->createRedirect('/middle', permanent: true, keepQueryString: false);
204+
$last = $this->createRedirect('/dest', permanent: true, keepQueryString: false);
205+
206+
$path = new RedirectionPath();
207+
$path->addRedirect($first);
208+
$path->addRedirect($last);
209+
210+
$this->channelContext->getChannel()->willThrow(new ChannelNotFoundException());
211+
$this->resolver->resolveFromRequest(Argument::cetera())->willReturn($path);
212+
213+
$manager = $this->prophesize(EntityManagerInterface::class);
214+
$manager->flush()->shouldBeCalledOnce();
215+
$this->managerRegistry->getManagerForClass(Redirect::class)->willReturn($manager->reveal());
216+
217+
$event = $this->createEvent(Request::create('/source'));
218+
219+
$this->createSubscriber()->onKernelRequest($event);
220+
221+
self::assertSame(1, $first->getCount());
222+
self::assertSame(1, $last->getCount());
223+
self::assertNotNull($first->getLastAccessed());
224+
self::assertNotNull($last->getLastAccessed());
225+
}
226+
227+
private function createSubscriber(): RequestSubscriber
228+
{
229+
return new RequestSubscriber(
230+
$this->managerRegistry->reveal(),
231+
$this->channelContext->reveal(),
232+
$this->resolver->reveal(),
233+
);
234+
}
235+
236+
private function createEvent(Request $request, int $type = HttpKernelInterface::MAIN_REQUEST): RequestEvent
237+
{
238+
return new RequestEvent($this->prophesize(HttpKernelInterface::class)->reveal(), $request, $type);
239+
}
240+
241+
private function createRedirect(string $destination, bool $permanent, bool $keepQueryString): RedirectInterface
242+
{
243+
$redirect = new Redirect();
244+
$redirect->setSource('/source');
245+
$redirect->setDestination($destination);
246+
$redirect->setPermanent($permanent);
247+
$redirect->setKeepQueryString($keepQueryString);
248+
249+
return $redirect;
250+
}
251+
}

0 commit comments

Comments
 (0)