From 4d48482d1e44ad2c733c52ba3f9d71e9ae640c7e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 21 Jul 2021 09:28:21 +0200 Subject: [PATCH] Added support to define differnet not-found redirects per domain --- config/autoload/url-shortener.local.php.dist | 5 +- data/migrations/Version20210720143824.php | 41 +++++ module/Core/config/dependencies.config.php | 7 +- .../Shlinkio.Shlink.Core.Entity.Domain.php | 15 ++ .../NotFoundRedirectConfigInterface.php | 20 +++ .../src/Config/NotFoundRedirectResolver.php | 34 ++++ .../NotFoundRedirectResolverInterface.php | 16 ++ module/Core/src/Domain/DomainService.php | 9 +- .../src/Domain/DomainServiceInterface.php | 2 + module/Core/src/Entity/Domain.php | 37 +++- .../ErrorHandler/NotFoundRedirectHandler.php | 35 ++-- .../src/Options/NotFoundRedirectOptions.php | 9 +- .../Config/NotFoundRedirectResolverTest.php | 114 +++++++++++++ .../NotFoundRedirectHandlerTest.php | 161 +++++++++--------- 14 files changed, 398 insertions(+), 107 deletions(-) create mode 100644 data/migrations/Version20210720143824.php create mode 100644 module/Core/src/Config/NotFoundRedirectConfigInterface.php create mode 100644 module/Core/src/Config/NotFoundRedirectResolver.php create mode 100644 module/Core/src/Config/NotFoundRedirectResolverInterface.php create mode 100644 module/Core/test/Config/NotFoundRedirectResolverTest.php diff --git a/config/autoload/url-shortener.local.php.dist b/config/autoload/url-shortener.local.php.dist index c686137f..f34245fb 100644 --- a/config/autoload/url-shortener.local.php.dist +++ b/config/autoload/url-shortener.local.php.dist @@ -2,13 +2,16 @@ declare(strict_types=1); +$isSwoole = extension_loaded('swoole'); + return [ 'url_shortener' => [ 'domain' => [ 'schema' => 'http', - 'hostname' => 'localhost:8080', + 'hostname' => sprintf('localhost:%s', $isSwoole ? '8080' : '8000'), ], + 'auto_resolve_titles' => true, ], ]; diff --git a/data/migrations/Version20210720143824.php b/data/migrations/Version20210720143824.php new file mode 100644 index 00000000..66e03be5 --- /dev/null +++ b/data/migrations/Version20210720143824.php @@ -0,0 +1,41 @@ +getTable('domains'); + $this->skipIf($domainsTable->hasColumn('base_url_redirect')); + + $this->createRedirectColumn($domainsTable, 'base_url_redirect'); + $this->createRedirectColumn($domainsTable, 'regular_not_found_redirect'); + $this->createRedirectColumn($domainsTable, 'invalid_short_url_redirect'); + } + + private function createRedirectColumn(Table $table, string $columnName): void + { + $table->addColumn($columnName, Types::STRING, [ + 'notnull' => false, + 'default' => null, + ]); + } + + public function down(Schema $schema): void + { + $domainsTable = $schema->getTable('domains'); + $this->skipIf(! $domainsTable->hasColumn('base_url_redirect')); + + $domainsTable->dropColumn('base_url_redirect'); + $domainsTable->dropColumn('regular_not_found_redirect'); + $domainsTable->dropColumn('invalid_short_url_redirect'); + } +} diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 03147bcb..a215a871 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -46,6 +46,8 @@ return [ Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class, Util\RedirectResponseHelper::class => ConfigAbstractFactory::class, + Config\NotFoundRedirectResolver::class => ConfigAbstractFactory::class, + Action\RedirectAction::class => ConfigAbstractFactory::class, Action\PixelAction::class => ConfigAbstractFactory::class, Action\QrCodeAction::class => ConfigAbstractFactory::class, @@ -75,7 +77,8 @@ return [ ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class], ErrorHandler\NotFoundRedirectHandler::class => [ NotFoundRedirectOptions::class, - Util\RedirectResponseHelper::class, + Config\NotFoundRedirectResolver::class, + Domain\DomainService::class, ], Options\AppOptions::class => ['config.app_options'], @@ -118,6 +121,8 @@ return [ Util\DoctrineBatchHelper::class => ['em'], Util\RedirectResponseHelper::class => [Options\UrlShortenerOptions::class], + Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class], + Action\RedirectAction::class => [ Service\ShortUrl\ShortUrlResolver::class, Visit\RequestTracker::class, diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php index e3d8c3cf..596f41da 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php @@ -24,4 +24,19 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createField('authority', Types::STRING) ->unique() ->build(); + + $builder->createField('baseUrlRedirect', Types::STRING) + ->columnName('base_url_redirect') + ->nullable() + ->build(); + + $builder->createField('regular404Redirect', Types::STRING) + ->columnName('regular_not_found_redirect') + ->nullable() + ->build(); + + $builder->createField('invalidShortUrlRedirect', Types::STRING) + ->columnName('invalid_short_url_redirect') + ->nullable() + ->build(); }; diff --git a/module/Core/src/Config/NotFoundRedirectConfigInterface.php b/module/Core/src/Config/NotFoundRedirectConfigInterface.php new file mode 100644 index 00000000..bbdfa9c5 --- /dev/null +++ b/module/Core/src/Config/NotFoundRedirectConfigInterface.php @@ -0,0 +1,20 @@ +isBaseUrl() && $config->hasBaseUrlRedirect() => + // @phpstan-ignore-next-line Create custom PHPStan rule + $this->redirectResponseHelper->buildRedirectResponse($config->baseUrlRedirect()), + $notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() => + // @phpstan-ignore-next-line Create custom PHPStan rule + $this->redirectResponseHelper->buildRedirectResponse($config->regular404Redirect()), + $notFoundType->isInvalidShortUrl() && $config->hasInvalidShortUrlRedirect() => + // @phpstan-ignore-next-line Create custom PHPStan rule + $this->redirectResponseHelper->buildRedirectResponse($config->invalidShortUrlRedirect()), + default => null, + }; + } +} diff --git a/module/Core/src/Config/NotFoundRedirectResolverInterface.php b/module/Core/src/Config/NotFoundRedirectResolverInterface.php new file mode 100644 index 00000000..a5c55f3d --- /dev/null +++ b/module/Core/src/Config/NotFoundRedirectResolverInterface.php @@ -0,0 +1,16 @@ +em->getRepository(Domain::class); - $domain = $repo->findOneBy(['authority' => $authority]) ?? new Domain($authority); + return $repo->findOneBy(['authority' => $authority]); + } + + public function getOrCreate(string $authority): Domain + { + $domain = $this->findByAuthority($authority) ?? new Domain($authority); $this->em->persist($domain); $this->em->flush(); diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index 3588fbc6..be357a22 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -22,4 +22,6 @@ interface DomainServiceInterface public function getDomain(string $domainId): Domain; public function getOrCreate(string $authority): Domain; + + public function findByAuthority(string $authority): ?Domain; } diff --git a/module/Core/src/Entity/Domain.php b/module/Core/src/Entity/Domain.php index ee094576..73b790c4 100644 --- a/module/Core/src/Entity/Domain.php +++ b/module/Core/src/Entity/Domain.php @@ -6,9 +6,14 @@ namespace Shlinkio\Shlink\Core\Entity; use JsonSerializable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; +use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; -class Domain extends AbstractEntity implements JsonSerializable +class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirectConfigInterface { + private ?string $baseUrlRedirect = null; + private ?string $regular404Redirect = null; + private ?string $invalidShortUrlRedirect = null; + public function __construct(private string $authority) { } @@ -22,4 +27,34 @@ class Domain extends AbstractEntity implements JsonSerializable { return $this->getAuthority(); } + + public function invalidShortUrlRedirect(): ?string + { + return $this->invalidShortUrlRedirect; + } + + public function hasInvalidShortUrlRedirect(): bool + { + return $this->invalidShortUrlRedirect !== null; + } + + public function regular404Redirect(): ?string + { + return $this->regular404Redirect; + } + + public function hasRegular404Redirect(): bool + { + return $this->regular404Redirect !== null; + } + + public function baseUrlRedirect(): ?string + { + return $this->baseUrlRedirect; + } + + public function hasBaseUrlRedirect(): bool + { + return $this->baseUrlRedirect !== null; + } } diff --git a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php index 93fd4597..44cd2ddd 100644 --- a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php @@ -8,15 +8,17 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface; +use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\Options; -use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; class NotFoundRedirectHandler implements MiddlewareInterface { public function __construct( private Options\NotFoundRedirectOptions $redirectOptions, - private RedirectResponseHelperInterface $redirectResponseHelper + private NotFoundRedirectResolverInterface $redirectResolver, + private DomainServiceInterface $domainService, ) { } @@ -24,26 +26,17 @@ class NotFoundRedirectHandler implements MiddlewareInterface { /** @var NotFoundType $notFoundType */ $notFoundType = $request->getAttribute(NotFoundType::class); + $authority = $request->getUri()->getAuthority(); + $domainSpecificRedirect = $this->resolveDomainSpecificRedirect($authority, $notFoundType); - if ($notFoundType->isBaseUrl() && $this->redirectOptions->hasBaseUrlRedirect()) { - // @phpstan-ignore-next-line Create custom PHPStan rule - return $this->redirectResponseHelper->buildRedirectResponse($this->redirectOptions->getBaseUrlRedirect()); - } + return $domainSpecificRedirect + ?? $this->redirectResolver->resolveRedirectResponse($notFoundType, $this->redirectOptions) + ?? $handler->handle($request); + } - if ($notFoundType->isRegularNotFound() && $this->redirectOptions->hasRegular404Redirect()) { - return $this->redirectResponseHelper->buildRedirectResponse( - // @phpstan-ignore-next-line Create custom PHPStan rule - $this->redirectOptions->getRegular404Redirect(), - ); - } - - if ($notFoundType->isInvalidShortUrl() && $this->redirectOptions->hasInvalidShortUrlRedirect()) { - return $this->redirectResponseHelper->buildRedirectResponse( - // @phpstan-ignore-next-line Create custom PHPStan rule - $this->redirectOptions->getInvalidShortUrlRedirect(), - ); - } - - return $handler->handle($request); + private function resolveDomainSpecificRedirect(string $authority, NotFoundType $notFoundType): ?ResponseInterface + { + $domain = $this->domainService->findByAuthority($authority); + return $domain === null ? null : $this->redirectResolver->resolveRedirectResponse($notFoundType, $domain); } } diff --git a/module/Core/src/Options/NotFoundRedirectOptions.php b/module/Core/src/Options/NotFoundRedirectOptions.php index 1bb3b828..2f2d813b 100644 --- a/module/Core/src/Options/NotFoundRedirectOptions.php +++ b/module/Core/src/Options/NotFoundRedirectOptions.php @@ -5,14 +5,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; use Laminas\Stdlib\AbstractOptions; +use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; -class NotFoundRedirectOptions extends AbstractOptions +class NotFoundRedirectOptions extends AbstractOptions implements NotFoundRedirectConfigInterface { private ?string $invalidShortUrl = null; private ?string $regular404 = null; private ?string $baseUrl = null; - public function getInvalidShortUrlRedirect(): ?string + public function invalidShortUrlRedirect(): ?string { return $this->invalidShortUrl; } @@ -28,7 +29,7 @@ class NotFoundRedirectOptions extends AbstractOptions return $this; } - public function getRegular404Redirect(): ?string + public function regular404Redirect(): ?string { return $this->regular404; } @@ -44,7 +45,7 @@ class NotFoundRedirectOptions extends AbstractOptions return $this; } - public function getBaseUrlRedirect(): ?string + public function baseUrlRedirect(): ?string { return $this->baseUrl; } diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php new file mode 100644 index 00000000..fe482a41 --- /dev/null +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -0,0 +1,114 @@ +helper = $this->prophesize(RedirectResponseHelperInterface::class); + $this->resolver = new NotFoundRedirectResolver($this->helper->reveal()); + + $this->config = new NotFoundRedirectOptions([ + 'invalidShortUrl' => 'invalidShortUrl', + 'regular404' => 'regular404', + 'baseUrl' => 'baseUrl', + ]); + } + + /** + * @test + * @dataProvider provideRedirects + */ + public function expectedRedirectionIsReturnedDependingOnTheCase( + NotFoundType $notFoundType, + string $expectedRedirectTo, + ): void { + $expectedResp = new Response(); + $buildResp = $this->helper->buildRedirectResponse($expectedRedirectTo)->willReturn($expectedResp); + + $resp = $this->resolver->resolveRedirectResponse($notFoundType, $this->config); + + self::assertSame($expectedResp, $resp); + $buildResp->shouldHaveBeenCalledOnce(); + } + + public function provideRedirects(): iterable + { + yield 'base URL with trailing slash' => [ + $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/'))), + 'baseUrl', + ]; + yield 'base URL without trailing slash' => [ + $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri(''))), + 'baseUrl', + ]; + yield 'regular 404' => [ + $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar'))), + 'regular404', + ]; + yield 'invalid short URL' => [ + $this->notFoundType($this->requestForRoute(RedirectAction::class)), + 'invalidShortUrl', + ]; + } + + /** @test */ + public function noResponseIsReturnedIfNoConditionsMatch(): void + { + $notFoundType = $this->notFoundType($this->requestForRoute('foo')); + + $result = $this->resolver->resolveRedirectResponse($notFoundType, $this->config); + + self::assertNull($result); + $this->helper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + private function notFoundType(ServerRequestInterface $req): NotFoundType + { + return NotFoundType::fromRequest($req, ''); + } + + private function requestForRoute(string $routeName): ServerRequestInterface + { + return ServerRequestFactory::fromGlobals() + ->withAttribute( + RouteResult::class, + RouteResult::fromRoute( + new Route( + '', + $this->prophesize(MiddlewareInterface::class)->reveal(), + ['GET'], + $routeName, + ), + ), + ) + ->withUri(new Uri('/abc123')); + } +} diff --git a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index f3054f49..b0f22710 100644 --- a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -6,21 +6,18 @@ namespace ShlinkioTest\Shlink\Core\ErrorHandler; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; -use Laminas\Diactoros\Uri; -use Mezzio\Router\Route; -use Mezzio\Router\RouteResult; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Shlinkio\Shlink\Core\Action\RedirectAction; +use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface; +use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; +use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\NotFoundRedirectHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; -use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; class NotFoundRedirectHandlerTest extends TestCase { @@ -28,93 +25,103 @@ class NotFoundRedirectHandlerTest extends TestCase private NotFoundRedirectHandler $middleware; private NotFoundRedirectOptions $redirectOptions; - private ObjectProphecy $helper; + private ObjectProphecy $resolver; + private ObjectProphecy $domainService; + private ObjectProphecy $next; + private ServerRequestInterface $req; public function setUp(): void { $this->redirectOptions = new NotFoundRedirectOptions(); - $this->helper = $this->prophesize(RedirectResponseHelperInterface::class); - $this->middleware = new NotFoundRedirectHandler($this->redirectOptions, $this->helper->reveal()); + $this->resolver = $this->prophesize(NotFoundRedirectResolverInterface::class); + $this->domainService = $this->prophesize(DomainServiceInterface::class); + + $this->middleware = new NotFoundRedirectHandler( + $this->redirectOptions, + $this->resolver->reveal(), + $this->domainService->reveal(), + ); + + $this->next = $this->prophesize(RequestHandlerInterface::class); + $this->req = ServerRequestFactory::fromGlobals()->withAttribute( + NotFoundType::class, + $this->prophesize(NotFoundType::class)->reveal(), + ); } /** * @test - * @dataProvider provideRedirects + * @dataProvider provideNonRedirectScenarios */ - public function expectedRedirectionIsReturnedDependingOnTheCase( - ServerRequestInterface $request, - string $expectedRedirectTo, - ): void { - $this->redirectOptions->invalidShortUrl = 'invalidShortUrl'; - $this->redirectOptions->regular404 = 'regular404'; - $this->redirectOptions->baseUrl = 'baseUrl'; - + public function nextIsCalledWhenNoRedirectIsResolved(callable $setUp): void + { $expectedResp = new Response(); - $buildResp = $this->helper->buildRedirectResponse($expectedRedirectTo)->willReturn($expectedResp); - $next = $this->prophesize(RequestHandlerInterface::class); - $handle = $next->handle($request)->willReturn(new Response()); + $setUp($this->domainService, $this->resolver); + $handle = $this->next->handle($this->req)->willReturn($expectedResp); - $resp = $this->middleware->process($request, $next->reveal()); + $result = $this->middleware->process($this->req, $this->next->reveal()); - self::assertSame($expectedResp, $resp); - $buildResp->shouldHaveBeenCalledOnce(); - $handle->shouldNotHaveBeenCalled(); - } - - public function provideRedirects(): iterable - { - yield 'base URL with trailing slash' => [ - $this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/'))), - 'baseUrl', - ]; - yield 'base URL without trailing slash' => [ - $this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri(''))), - 'baseUrl', - ]; - yield 'regular 404' => [ - $this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar'))), - 'regular404', - ]; - yield 'invalid short URL' => [ - $this->withNotFoundType(ServerRequestFactory::fromGlobals() - ->withAttribute( - RouteResult::class, - RouteResult::fromRoute( - new Route( - '', - $this->prophesize(MiddlewareInterface::class)->reveal(), - ['GET'], - RedirectAction::class, - ), - ), - ) - ->withUri(new Uri('/abc123'))), - 'invalidShortUrl', - ]; - } - - /** @test */ - public function nextMiddlewareIsInvokedWhenNotRedirectNeedsToOccur(): void - { - $req = $this->withNotFoundType(ServerRequestFactory::fromGlobals()); - $resp = new Response(); - - $buildResp = $this->helper->buildRedirectResponse(Argument::cetera()); - - $next = $this->prophesize(RequestHandlerInterface::class); - $handle = $next->handle($req)->willReturn($resp); - - $result = $this->middleware->process($req, $next->reveal()); - - self::assertSame($resp, $result); - $buildResp->shouldNotHaveBeenCalled(); + self::assertSame($expectedResp, $result); $handle->shouldHaveBeenCalledOnce(); } - private function withNotFoundType(ServerRequestInterface $req): ServerRequestInterface + public function provideNonRedirectScenarios(): iterable { - $type = NotFoundType::fromRequest($req, ''); - return $req->withAttribute(NotFoundType::class, $type); + yield 'no domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void { + $domainService->findByAuthority(Argument::cetera()) + ->willReturn(null) + ->shouldBeCalledOnce(); + $resolver->resolveRedirectResponse(Argument::cetera()) + ->willReturn(null) + ->shouldBeCalledOnce(); + }]; + yield 'non-redirecting domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void { + $domainService->findByAuthority(Argument::cetera()) + ->willReturn(new Domain('')) + ->shouldBeCalledOnce(); + $resolver->resolveRedirectResponse(Argument::cetera()) + ->willReturn(null) + ->shouldBeCalledTimes(2); + }]; + } + + /** @test */ + public function globalRedirectIsUsedIfDomainRedirectIsNotFound(): void + { + $expectedResp = new Response(); + + $findDomain = $this->domainService->findByAuthority(Argument::cetera())->willReturn(null); + $resolveRedirect = $this->resolver->resolveRedirectResponse( + Argument::type(NotFoundType::class), + $this->redirectOptions, + )->willReturn($expectedResp); + + $result = $this->middleware->process($this->req, $this->next->reveal()); + + self::assertSame($expectedResp, $result); + $findDomain->shouldHaveBeenCalledOnce(); + $resolveRedirect->shouldHaveBeenCalledOnce(); + $this->next->handle(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function domainRedirectIsUsedIfFound(): void + { + $expectedResp = new Response(); + $domain = new Domain(''); + + $findDomain = $this->domainService->findByAuthority(Argument::cetera())->willReturn($domain); + $resolveRedirect = $this->resolver->resolveRedirectResponse( + Argument::type(NotFoundType::class), + $domain, + )->willReturn($expectedResp); + + $result = $this->middleware->process($this->req, $this->next->reveal()); + + self::assertSame($expectedResp, $result); + $findDomain->shouldHaveBeenCalledOnce(); + $resolveRedirect->shouldHaveBeenCalledOnce(); + $this->next->handle(Argument::cetera())->shouldNotHaveBeenCalled(); } }