From 146ba8a906d1c1df497f9c29a462bcb382f26f66 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 26 Dec 2025 23:33:56 +0100 Subject: [PATCH 1/6] cs --- src/Application/LinkGenerator.php | 2 +- src/Application/MicroPresenter.php | 4 ++-- src/Application/Routers/Route.php | 2 +- src/Application/Routers/RouteList.php | 2 +- src/Application/UI/AccessPolicy.php | 3 +++ src/Application/UI/Component.php | 7 ++++--- src/Application/UI/ParameterConverter.php | 4 ++++ src/Bridges/ApplicationDI/ApplicationExtension.php | 1 + src/Bridges/ApplicationLatte/SnippetRuntime.php | 2 +- src/Bridges/ApplicationLatte/TemplateFactory.php | 6 +++--- src/Bridges/ApplicationLatte/UIExtension.php | 2 +- 11 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/Application/LinkGenerator.php b/src/Application/LinkGenerator.php index 122fdfc7b..13877f6ec 100644 --- a/src/Application/LinkGenerator.php +++ b/src/Application/LinkGenerator.php @@ -273,7 +273,7 @@ public function requestToUrl(Request $request, ?bool $relative = false): string if ($relative) { $hostUrl = $this->refUrl->getHostUrl() . '/'; - if (strncmp($url, $hostUrl, strlen($hostUrl)) === 0) { + if (str_starts_with($url, $hostUrl)) { $url = substr($url, strlen($hostUrl) - 1); } } diff --git a/src/Application/MicroPresenter.php b/src/Application/MicroPresenter.php index b68eea59e..f0312f828 100644 --- a/src/Application/MicroPresenter.php +++ b/src/Application/MicroPresenter.php @@ -115,7 +115,7 @@ public function createTemplate(?string $class = null, ?callable $latteFactory = { $latte = $latteFactory ? $latteFactory() - : $this->getContext()->getByType(Nette\Bridges\ApplicationLatte\LatteFactory::class)->create(); + : $this->context->getByType(Nette\Bridges\ApplicationLatte\LatteFactory::class)->create(); $template = $class ? new $class : new Nette\Bridges\ApplicationLatte\DefaultTemplate($latte); @@ -146,7 +146,7 @@ public function redirectUrl(string $url, int $httpCode = Http\IResponse::S302_Fo * Throws HTTP error. * @throws Nette\Application\BadRequestException */ - public function error(string $message = '', int $httpCode = Http\IResponse::S404_NotFound): void + public function error(string $message = '', int $httpCode = Http\IResponse::S404_NotFound): never { throw new Application\BadRequestException($message, $httpCode); } diff --git a/src/Application/Routers/Route.php b/src/Application/Routers/Route.php index fbc85f3bf..3e7b5410a 100644 --- a/src/Application/Routers/Route.php +++ b/src/Application/Routers/Route.php @@ -87,7 +87,7 @@ public function match(Nette\Http\IRequest $httpRequest): ?array $presenter = $params[self::PresenterKey] ?? null; if (isset($this->getMetadata()[self::ModuleKey], $params[self::ModuleKey]) && is_string($presenter)) { - $params[self::PresenterKey] = $params[self::ModuleKey] . ':' . $params[self::PresenterKey]; + $params[self::PresenterKey] = $params[self::ModuleKey] . ':' . $presenter; } unset($params[self::ModuleKey]); diff --git a/src/Application/Routers/RouteList.php b/src/Application/Routers/RouteList.php index 463589ee5..74b25363a 100644 --- a/src/Application/Routers/RouteList.php +++ b/src/Application/Routers/RouteList.php @@ -51,7 +51,7 @@ protected function completeParameters(array $params): ?array public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?string { if ($this->module) { - if (strncmp($params[self::PresenterKey], $this->module, strlen($this->module)) !== 0) { + if (!str_starts_with($params[self::PresenterKey], $this->module)) { return null; } diff --git a/src/Application/UI/AccessPolicy.php b/src/Application/UI/AccessPolicy.php index 4c4ae29b4..0d13df955 100644 --- a/src/Application/UI/AccessPolicy.php +++ b/src/Application/UI/AccessPolicy.php @@ -62,6 +62,9 @@ private function getAttributes(): array } + /** + * @param Requires[] $attrs + */ private function applyInternalRules(array $attrs, Component $component): array { if ( diff --git a/src/Application/UI/Component.php b/src/Application/UI/Component.php index e4feedfe2..e14ac4361 100644 --- a/src/Application/UI/Component.php +++ b/src/Application/UI/Component.php @@ -102,7 +102,7 @@ protected function validateParent(Nette\ComponentModel\IContainer $parent): void */ protected function tryCall(string $method, array $params): bool { - $rc = $this->getReflection(); + $rc = static::getReflection(); if (!$rc->hasMethod($method)) { return false; } elseif (!$rc->hasCallableMethod($method)) { @@ -149,7 +149,7 @@ public static function getReflection(): ComponentReflection */ public function loadState(array $params): void { - $reflection = $this->getReflection(); + $reflection = static::getReflection(); foreach ($reflection->getParameters() as $name => $meta) { if (isset($params[$name])) { // nulls are ignored if (!ParameterConverter::convertType($params[$name], $meta['type'])) { @@ -183,6 +183,7 @@ public function saveState(array &$params): void /** * @internal used by presenter + * @param array $params */ public function saveStatePartial(array &$params, ComponentReflection $reflection): void { @@ -260,7 +261,7 @@ final public function getParameterId(string $name): string */ public function signalReceived(string $signal): void { - if (!$this->tryCall($this->formatSignalMethod($signal), $this->params)) { + if (!$this->tryCall(static::formatSignalMethod($signal), $this->params)) { $class = static::class; throw new BadSignalException("There is no handler for signal '$signal' in class $class."); } diff --git a/src/Application/UI/ParameterConverter.php b/src/Application/UI/ParameterConverter.php index c41935be4..4a699c20f 100644 --- a/src/Application/UI/ParameterConverter.php +++ b/src/Application/UI/ParameterConverter.php @@ -22,6 +22,9 @@ final class ParameterConverter { use Nette\StaticClass; + /** + * @param array $args + */ public static function toArguments(\ReflectionFunctionAbstract $method, array $args): array { $res = []; @@ -60,6 +63,7 @@ public static function toArguments(\ReflectionFunctionAbstract $method, array $a /** * Converts list of arguments to named parameters & check types. + * @param array $supplemental * @param \ReflectionParameter[] $missing arguments * @throws InvalidLinkException * @internal diff --git a/src/Bridges/ApplicationDI/ApplicationExtension.php b/src/Bridges/ApplicationDI/ApplicationExtension.php index d05e39b19..476472cd0 100644 --- a/src/Bridges/ApplicationDI/ApplicationExtension.php +++ b/src/Bridges/ApplicationDI/ApplicationExtension.php @@ -25,6 +25,7 @@ */ final class ApplicationExtension extends Nette\DI\CompilerExtension { + /** @var string[] */ private readonly array $scanDirs; private int $invalidLinkMode; private array $checked = []; diff --git a/src/Bridges/ApplicationLatte/SnippetRuntime.php b/src/Bridges/ApplicationLatte/SnippetRuntime.php index 71867a384..a3429f297 100644 --- a/src/Bridges/ApplicationLatte/SnippetRuntime.php +++ b/src/Bridges/ApplicationLatte/SnippetRuntime.php @@ -65,7 +65,7 @@ public function enter(string $name, string $type): void $this->stack[] = [$name, $obStarted]; if ($name !== '') { - $this->control->redrawControl($name, false); + $this->control->redrawControl($name, redraw: false); } } diff --git a/src/Bridges/ApplicationLatte/TemplateFactory.php b/src/Bridges/ApplicationLatte/TemplateFactory.php index f60226a3c..676b31da6 100644 --- a/src/Bridges/ApplicationLatte/TemplateFactory.php +++ b/src/Bridges/ApplicationLatte/TemplateFactory.php @@ -32,7 +32,7 @@ public function __construct( private readonly ?Nette\Caching\Storage $cacheStorage = null, $templateClass = null, ) { - if ($templateClass && (!class_exists($templateClass) || !is_a($templateClass, Template::class, true))) { + if ($templateClass && (!class_exists($templateClass) || !is_a($templateClass, Template::class, allow_string: true))) { throw new Nette\InvalidArgumentException("Class $templateClass does not implement " . Template::class . ' or it does not exist.'); } @@ -139,8 +139,8 @@ private function setupLatte2( } if ($presenter) { - $latte->addFunction('isLinkCurrent', [$presenter, 'isLinkCurrent']); - $latte->addFunction('isModuleCurrent', [$presenter, 'isModuleCurrent']); + $latte->addFunction('isLinkCurrent', $presenter->isLinkCurrent(...)); + $latte->addFunction('isModuleCurrent', $presenter->isModuleCurrent(...)); } $latte->addFilter('modifyDate', fn($time, $delta, $unit = null) => $time diff --git a/src/Bridges/ApplicationLatte/UIExtension.php b/src/Bridges/ApplicationLatte/UIExtension.php index 965d83c03..2805dff96 100644 --- a/src/Bridges/ApplicationLatte/UIExtension.php +++ b/src/Bridges/ApplicationLatte/UIExtension.php @@ -94,7 +94,7 @@ public function getPasses(): array { return [ 'snippetRendering' => $this->snippetRenderingPass(...), - 'applyLinkBase' => [Nodes\LinkBaseNode::class, 'applyLinkBasePass'], + 'applyLinkBase' => Nodes\LinkBaseNode::applyLinkBasePass(...), ]; } From e15baf2637313c80f9cf4dcd87c2437a46d204f0 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 29 Dec 2025 23:18:13 +0100 Subject: [PATCH 2/6] updated .gitattributes & .gitignore --- .gitattributes | 16 ++++++++-------- .gitignore | 1 + 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.gitattributes b/.gitattributes index 9670e954e..e1bccc4bf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,9 @@ -.gitattributes export-ignore -.gitignore export-ignore -.github export-ignore -ncs.* export-ignore -phpstan.neon export-ignore -tests/ export-ignore +.gitattributes export-ignore +.github/ export-ignore +.gitignore export-ignore +ncs.* export-ignore +phpstan*.neon export-ignore +tests/ export-ignore -*.sh eol=lf -*.php* diff=php linguist-language=PHP +*.php* diff=php +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index de4a392c3..d49bcd46e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /vendor /composer.lock +tests/lock From ea236135455a0887a56f96da760496b12caaaeea Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 26 Dec 2025 23:37:40 +0100 Subject: [PATCH 3/6] promoted readonly properties --- src/Application/Application.php | 16 ++++------------ src/Application/Responses/FileResponse.php | 14 ++++---------- src/Application/Responses/ForwardResponse.php | 9 +++------ src/Application/Responses/JsonResponse.php | 12 ++++-------- src/Application/Responses/RedirectResponse.php | 12 ++++-------- src/Application/Responses/TextResponse.php | 9 +++------ src/Application/Routers/CliRouter.php | 8 +++----- 7 files changed, 25 insertions(+), 55 deletions(-) diff --git a/src/Application/Application.php b/src/Application/Application.php index be96dd49c..22b5673a9 100644 --- a/src/Application/Application.php +++ b/src/Application/Application.php @@ -48,22 +48,14 @@ class Application /** @var Request[] */ private array $requests = []; private ?IPresenter $presenter = null; - private Nette\Http\IRequest $httpRequest; - private Nette\Http\IResponse $httpResponse; - private IPresenterFactory $presenterFactory; - private Router $router; public function __construct( - IPresenterFactory $presenterFactory, - Router $router, - Nette\Http\IRequest $httpRequest, - Nette\Http\IResponse $httpResponse, + private readonly IPresenterFactory $presenterFactory, + private readonly Router $router, + private readonly Nette\Http\IRequest $httpRequest, + private readonly Nette\Http\IResponse $httpResponse, ) { - $this->httpRequest = $httpRequest; - $this->httpResponse = $httpResponse; - $this->presenterFactory = $presenterFactory; - $this->router = $router; } diff --git a/src/Application/Responses/FileResponse.php b/src/Application/Responses/FileResponse.php index f45b74bb8..4c98f3d9d 100644 --- a/src/Application/Responses/FileResponse.php +++ b/src/Application/Responses/FileResponse.php @@ -19,26 +19,20 @@ final class FileResponse implements Nette\Application\Response { public bool $resuming = true; - private string $file; - private string $contentType; - private string $name; - private bool $forceDownload; + private readonly string $name; public function __construct( - string $file, + private readonly string $file, ?string $name = null, - ?string $contentType = null, - bool $forceDownload = true, + private readonly string $contentType = 'application/octet-stream', + private readonly bool $forceDownload = true, ) { if (!is_file($file) || !is_readable($file)) { throw new Nette\Application\BadRequestException("File '$file' doesn't exist or is not readable."); } - $this->file = $file; $this->name = $name ?? basename($file); - $this->contentType = $contentType ?? 'application/octet-stream'; - $this->forceDownload = $forceDownload; } diff --git a/src/Application/Responses/ForwardResponse.php b/src/Application/Responses/ForwardResponse.php index 9e68c16ca..23675669c 100644 --- a/src/Application/Responses/ForwardResponse.php +++ b/src/Application/Responses/ForwardResponse.php @@ -17,12 +17,9 @@ */ final class ForwardResponse implements Nette\Application\Response { - private Nette\Application\Request $request; - - - public function __construct(Nette\Application\Request $request) - { - $this->request = $request; + public function __construct( + private readonly Nette\Application\Request $request, + ) { } diff --git a/src/Application/Responses/JsonResponse.php b/src/Application/Responses/JsonResponse.php index 1feb0d6f4..998435b42 100644 --- a/src/Application/Responses/JsonResponse.php +++ b/src/Application/Responses/JsonResponse.php @@ -17,14 +17,10 @@ */ final class JsonResponse implements Nette\Application\Response { - private mixed $payload; - private string $contentType; - - - public function __construct(mixed $payload, ?string $contentType = null) - { - $this->payload = $payload; - $this->contentType = $contentType ?? 'application/json'; + public function __construct( + private readonly mixed $payload, + private readonly string $contentType = 'application/json', + ) { } diff --git a/src/Application/Responses/RedirectResponse.php b/src/Application/Responses/RedirectResponse.php index 1ab884f1a..9974b0c8c 100644 --- a/src/Application/Responses/RedirectResponse.php +++ b/src/Application/Responses/RedirectResponse.php @@ -18,14 +18,10 @@ */ final class RedirectResponse implements Nette\Application\Response { - private string $url; - private int $httpCode; - - - public function __construct(string $url, int $httpCode = Http\IResponse::S302_Found) - { - $this->url = $url; - $this->httpCode = $httpCode; + public function __construct( + private readonly string $url, + private readonly int $httpCode = Http\IResponse::S302_Found, + ) { } diff --git a/src/Application/Responses/TextResponse.php b/src/Application/Responses/TextResponse.php index e21fb5168..ac486472d 100644 --- a/src/Application/Responses/TextResponse.php +++ b/src/Application/Responses/TextResponse.php @@ -17,12 +17,9 @@ */ final class TextResponse implements Nette\Application\Response { - private mixed $source; - - - public function __construct(mixed $source) - { - $this->source = $source; + public function __construct( + private readonly mixed $source, + ) { } diff --git a/src/Application/Routers/CliRouter.php b/src/Application/Routers/CliRouter.php index 7e261fdf0..589fc898b 100644 --- a/src/Application/Routers/CliRouter.php +++ b/src/Application/Routers/CliRouter.php @@ -20,12 +20,10 @@ final class CliRouter implements Nette\Routing\Router { private const PresenterKey = 'action'; - private array $defaults; - - public function __construct(array $defaults = []) - { - $this->defaults = $defaults; + public function __construct( + private readonly array $defaults = [], + ) { } From 8e4fd52316734baab8395f61824e8182a3fd8d62 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 29 Dec 2025 00:34:07 +0100 Subject: [PATCH 4/6] improved tests --- .github/workflows/tests.yml | 6 +- tests/UI/Component.signals.phpt | 174 +++++++++++++++++ tests/UI/Multiplier.phpt | 77 ++++++++ tests/UI/Presenter.canonicalize.phpt | 230 ++++++++++++++++++++++ tests/UI/Presenter.flashMessage.phpt | 257 +++++++++++++++++++++++++ tests/UI/Presenter.lifecycle.phpt | 248 ++++++++++++++++++++++++ tests/UI/Presenter.redirects.phpt | 241 +++++++++++++++++++++++ tests/UI/Presenter.storeRequest().phpt | 137 +++++++++++++ tests/UI/Requires.combinations.phpt | 255 ++++++++++++++++++++++++ 9 files changed, 1622 insertions(+), 3 deletions(-) create mode 100644 tests/UI/Component.signals.phpt create mode 100644 tests/UI/Multiplier.phpt create mode 100644 tests/UI/Presenter.canonicalize.phpt create mode 100644 tests/UI/Presenter.flashMessage.phpt create mode 100644 tests/UI/Presenter.lifecycle.phpt create mode 100644 tests/UI/Presenter.redirects.phpt create mode 100644 tests/UI/Requires.combinations.phpt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 60067d232..9f017cec9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,7 +20,7 @@ jobs: coverage: none - run: composer install --no-progress --prefer-dist - - run: vendor/bin/tester tests -s -C + - run: composer tester - if: failure() uses: actions/upload-artifact@v4 with: @@ -39,7 +39,7 @@ jobs: coverage: none - run: composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable - - run: vendor/bin/tester tests -s -C + - run: composer tester code_coverage: @@ -53,7 +53,7 @@ jobs: coverage: none - run: composer install --no-progress --prefer-dist - - run: vendor/bin/tester -p phpdbg tests -s -C --coverage ./coverage.xml --coverage-src ./src + - run: composer tester -- -p phpdbg --coverage ./coverage.xml --coverage-src ./src - run: wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.4.3/php-coveralls.phar - env: COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/UI/Component.signals.phpt b/tests/UI/Component.signals.phpt new file mode 100644 index 000000000..5a96b87a3 --- /dev/null +++ b/tests/UI/Component.signals.phpt @@ -0,0 +1,174 @@ +signalCalled = true; + } +} + + +class ParentControl extends Application\UI\Control +{ + public bool $signalCalled = false; + + + public function handleUpdate(): void + { + $this->signalCalled = true; + } + + + protected function createComponentNested(): NestedControl + { + return new NestedControl; + } +} + + +test('signal routing to nested component', function () { + $presenter = new class extends Application\UI\Presenter { + protected function createComponentParent(): ParentControl + { + return new ParentControl; + } + + + public function renderDefault(): void + { + $this->terminate(); + } + }; + + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript, cookies: [Http\Helpers::StrictCookieName => 1]), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Test', 'GET', [ + 'action' => 'default', + 'do' => 'parent-nested-refresh', + ])); + + $parent = $presenter['parent']; + $nested = $parent['nested']; + + Assert::type(ParentControl::class, $parent); + Assert::type(NestedControl::class, $nested); + Assert::false($parent->signalCalled); + Assert::true($nested->signalCalled); +}); + + +class ParameterControl extends Application\UI\Control +{ + public ?int $receivedParam = null; + + + public function handleClick(int $id): void + { + $this->receivedParam = $id; + } +} + + +test('signal with parameters', function () { + $presenter = new class extends Application\UI\Presenter { + protected function createComponentParam(): ParameterControl + { + return new ParameterControl; + } + + + public function renderDefault(): void + { + $this->terminate(); + } + }; + + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript, cookies: [Http\Helpers::StrictCookieName => 1]), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Test', 'GET', [ + 'action' => 'default', + 'do' => 'param-click', + 'param-id' => '42', + ])); + + $control = $presenter['param']; + + Assert::type(ParameterControl::class, $control); + Assert::same(42, $control->receivedParam); +}); + + +testException('invalid signal name throws exception', function () { + $presenter = new class extends Application\UI\Presenter { + public function renderDefault(): void + { + $this->terminate(); + } + }; + + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript, cookies: [Http\Helpers::StrictCookieName => 1]), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Test', 'GET', [ + 'action' => 'default', + 'do' => 'nonexistent', + ])); +}, Application\UI\BadSignalException::class); + + +testException('signal to nonexistent component throws exception', function () { + $presenter = new class extends Application\UI\Presenter { + public function renderDefault(): void + { + $this->terminate(); + } + }; + + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript, cookies: [Http\Helpers::StrictCookieName => 1]), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Test', 'GET', [ + 'action' => 'default', + 'do' => 'nonexistent-click', + ])); +}, Application\UI\BadSignalException::class); diff --git a/tests/UI/Multiplier.phpt b/tests/UI/Multiplier.phpt new file mode 100644 index 000000000..6838b91d9 --- /dev/null +++ b/tests/UI/Multiplier.phpt @@ -0,0 +1,77 @@ +getComponent('item1'); + $component2 = $multiplier->getComponent('item2'); + + Assert::type(TestControl::class, $component1); + Assert::type(TestControl::class, $component2); + Assert::same('item1', $component1->id); + Assert::same('item2', $component2->id); + Assert::same(['item1', 'item2'], $calls); +}); + + +test('same name returns cached component', function () { + $calls = 0; + $multiplier = new Application\UI\Multiplier(function ($name) use (&$calls) { + $calls++; + return new TestControl($name); + }); + + $component1 = $multiplier->getComponent('item1'); + $component2 = $multiplier->getComponent('item1'); + + Assert::same($component1, $component2); + Assert::same(1, $calls); +}); + + +test('factory receives component name and parent', function () { + $receivedName = null; + $receivedParent = null; + + $multiplier = new Application\UI\Multiplier(function ($name, $parent) use (&$receivedName, &$receivedParent) { + $receivedName = $name; + $receivedParent = $parent; + return new TestControl($name); + }); + + $multiplier->getComponent('test'); + + Assert::same('test', $receivedName); + Assert::same($multiplier, $receivedParent); +}); diff --git a/tests/UI/Presenter.canonicalize.phpt b/tests/UI/Presenter.canonicalize.phpt new file mode 100644 index 000000000..306f873ba --- /dev/null +++ b/tests/UI/Presenter.canonicalize.phpt @@ -0,0 +1,230 @@ +terminate(); + } +} + + +test('autoCanonicalize redirects to remove default persistent parameter', function () { + $presenter = new CanonicalPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript('http://localhost/index.php?id=0&presenter=Canonical&action=default')), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + ); + $presenter->autoCanonicalize = true; + + $response = $presenter->run(new Application\Request('Canonical', 'GET', [ + 'action' => 'default', + 'id' => 0, + ])); + + Assert::type(Application\Responses\RedirectResponse::class, $response); + Assert::notContains('id=0', $response->getUrl()); +}); + + +test('autoCanonicalize disabled does not redirect', function () { + $presenter = new CanonicalPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript('http://localhost/index.php?extra=param&id=5&presenter=Canonical&action=default')), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + ); + $presenter->autoCanonicalize = false; + + $response = $presenter->run(new Application\Request('Canonical', 'GET', [ + 'action' => 'default', + 'id' => 5, + ])); + + Assert::type(Application\Responses\VoidResponse::class, $response); +}); + + +test('canonicalize() does not redirect on AJAX request', function () { + $presenter = createPresenter(CanonicalPresenter::class, headers: ['X-Requested-With' => 'XMLHttpRequest']); + $presenter->autoCanonicalize = true; + + $response = $presenter->run(new Application\Request('Canonical', 'GET', [ + 'action' => 'default', + 'id' => 5, + ])); + + Assert::type(Application\Responses\VoidResponse::class, $response); +}); + + +test('canonicalize() does not redirect on POST request', function () { + $presenter = createPresenter(CanonicalPresenter::class, post: ['data' => 'value']); + $presenter->autoCanonicalize = true; + + $response = $presenter->run(new Application\Request('Canonical', 'POST', [ + 'action' => 'default', + 'id' => 5, + ])); + + Assert::type(Application\Responses\VoidResponse::class, $response); +}); + + +class ManualCanonicalPresenter extends Application\UI\Presenter +{ + public function actionDefault(): void + { + $this->canonicalize(); + } + + + public function renderDefault(): void + { + $this->terminate(); + } +} + + +test('manual canonicalize call works', function () { + $presenter = new ManualCanonicalPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript('http://localhost/index.php?extra=param&presenter=ManualCanonical&action=default')), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + ); + $presenter->autoCanonicalize = false; + + $response = $presenter->run(new Application\Request('ManualCanonical', 'GET', [ + 'action' => 'default', + ])); + + Assert::type(Application\Responses\RedirectResponse::class, $response); +}); + + +class VaryingPresenter extends Application\UI\Presenter +{ + public function actionDefault(): void + { + } + + + public function renderDefault(): void + { + $this->terminate(); + } +} + + +test('canonicalize() uses 301 for non-varying requests', function () { + $presenter = new VaryingPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript('http://localhost/index.php?extra=param&presenter=Varying&action=default')), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + ); + $presenter->autoCanonicalize = true; + + $response = $presenter->run(new Application\Request('Varying', 'GET', [ + 'action' => 'default', + ])); + + Assert::type(Application\Responses\RedirectResponse::class, $response); + Assert::same(Http\IResponse::S301_MovedPermanently, $response->getCode()); +}); + + +test('canonicalize() uses 302 for varying requests', function () { + $presenter = new VaryingPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript('http://localhost/index.php?extra=param&presenter=Varying&action=default')), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + ); + $presenter->autoCanonicalize = true; + + $request = new Application\Request('Varying', 'GET', ['action' => 'default']); + $request->setFlag($request::VARYING, true); + + $response = $presenter->run($request); + + Assert::type(Application\Responses\RedirectResponse::class, $response); + Assert::same(Http\IResponse::S302_Found, $response->getCode()); +}); + + +class DestinationCanonicalPresenter extends Application\UI\Presenter +{ + public function actionDefault(): void + { + $this->canonicalize('other'); + } + + + public function actionOther(): void + { + } + + + public function renderDefault(): void + { + $this->terminate(); + } + + + public function renderOther(): void + { + $this->terminate(); + } +} + + +test('canonicalize() with different destination redirects', function () { + $presenter = new DestinationCanonicalPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript('http://localhost/index.php?presenter=DestinationCanonical&action=default')), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + ); + $presenter->autoCanonicalize = false; + + $response = $presenter->run(new Application\Request('DestinationCanonical', 'GET', [ + 'action' => 'default', + ])); + + Assert::type(Application\Responses\RedirectResponse::class, $response); + Assert::contains('action=other', $response->getUrl()); +}); diff --git a/tests/UI/Presenter.flashMessage.phpt b/tests/UI/Presenter.flashMessage.phpt new file mode 100644 index 000000000..9f5bee7da --- /dev/null +++ b/tests/UI/Presenter.flashMessage.phpt @@ -0,0 +1,257 @@ +terminate(); + } +} + + +class FlashPresenter extends Application\UI\Presenter +{ + public function actionDefault() + { + $this->flashMessage('Test message'); + } + + + public function renderDefault() + { + $this->terminate(); + } +} + + +test('flash message is stored in session', function () { + $sessionSection = Mockery::mock(Http\SessionSection::class); + $sessionSection->shouldReceive('get')->andReturn([]); + $sessionSection->shouldReceive('set')->once(); + + $session = Mockery::mock(Http\Session::class); + $session->shouldReceive('getSection')->andReturn($sessionSection); + $session->shouldReceive('hasSection')->andReturn(true); + + $latte = Mockery::mock(Latte\Engine::class); + $latte->shouldIgnoreMissing(); + $templateFactory = Mockery::mock(Application\UI\TemplateFactory::class); + $templateFactory->shouldReceive('createTemplate')->andReturn(new Nette\Bridges\ApplicationLatte\DefaultTemplate($latte)); + + $presenter = new TestPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + session: $session, + templateFactory: $templateFactory, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Test', 'GET', ['action' => 'default'])); + + $flash = $presenter->flashMessage('Test message'); + Assert::type(stdClass::class, $flash); + Assert::same('Test message', $flash->message); + Assert::same('info', $flash->type); +}); + + +test('flash message with custom type', function () { + $sessionSection = Mockery::mock(Http\SessionSection::class); + $sessionSection->shouldReceive('get')->andReturn([]); + $sessionSection->shouldReceive('set')->once(); + + $session = Mockery::mock(Http\Session::class); + $session->shouldReceive('getSection')->andReturn($sessionSection); + $session->shouldReceive('hasSection')->andReturn(true); + + $latte = Mockery::mock(Latte\Engine::class); + $latte->shouldIgnoreMissing(); + $templateFactory = Mockery::mock(Application\UI\TemplateFactory::class); + $templateFactory->shouldReceive('createTemplate')->andReturn(new Nette\Bridges\ApplicationLatte\DefaultTemplate($latte)); + + $presenter = new TestPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + session: $session, + templateFactory: $templateFactory, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Test', 'GET', ['action' => 'default'])); + + $flash = $presenter->flashMessage('Error occurred', 'error'); + Assert::same('Error occurred', $flash->message); + Assert::same('error', $flash->type); +}); + + +test('multiple flash messages are stored', function () { + $messages = []; + $sessionSection = Mockery::mock(Http\SessionSection::class); + $sessionSection->shouldReceive('get')->andReturnUsing(function () use (&$messages) { + return $messages; + }); + $sessionSection->shouldReceive('set')->andReturnUsing(function ($id, $value) use (&$messages) { + $messages = $value; + }); + + $session = Mockery::mock(Http\Session::class); + $session->shouldReceive('getSection')->andReturn($sessionSection); + $session->shouldReceive('hasSection')->andReturn(true); + + $latte = Mockery::mock(Latte\Engine::class); + $latte->shouldIgnoreMissing(); + $templateFactory = Mockery::mock(Application\UI\TemplateFactory::class); + $templateFactory->shouldReceive('createTemplate')->andReturn(new Nette\Bridges\ApplicationLatte\DefaultTemplate($latte)); + + $presenter = new TestPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + session: $session, + templateFactory: $templateFactory, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Test', 'GET', ['action' => 'default'])); + + $presenter->flashMessage('First message', 'info'); + $presenter->flashMessage('Second message', 'error'); + $presenter->flashMessage('Third message', 'success'); + + Assert::count(3, $messages); + Assert::same('First message', $messages[0]->message); + Assert::same('info', $messages[0]->type); + Assert::same('Second message', $messages[1]->message); + Assert::same('error', $messages[1]->type); + Assert::same('Third message', $messages[2]->message); + Assert::same('success', $messages[2]->type); +}); + + +test('flash message is available in template', function () { + $messages = []; + $sessionSection = Mockery::mock(Http\SessionSection::class); + $sessionSection->shouldReceive('get')->andReturnUsing(function () use (&$messages) { + return $messages; + }); + $sessionSection->shouldReceive('set')->andReturnUsing(function ($id, $value) use (&$messages) { + $messages = $value; + }); + + $session = Mockery::mock(Http\Session::class); + $session->shouldReceive('getSection')->andReturn($sessionSection); + $session->shouldReceive('hasSection')->andReturn(true); + + $latte = Mockery::mock(Latte\Engine::class); + $latte->shouldIgnoreMissing(); + $templateFactory = Mockery::mock(Application\UI\TemplateFactory::class); + $templateFactory->shouldReceive('createTemplate')->andReturn(new Nette\Bridges\ApplicationLatte\DefaultTemplate($latte)); + + $presenter = new TestPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + session: $session, + templateFactory: $templateFactory, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Test', 'GET', ['action' => 'default'])); + + $presenter->flashMessage('Template message'); + + $template = $presenter->getTemplate(); + Assert::count(1, $template->flashes); + Assert::same('Template message', $template->flashes[0]->message); +}); + + +test('flash session expires after presenter run', function () { + $expirationSet = false; + $sessionSection = Mockery::mock(Http\SessionSection::class); + $sessionSection->shouldReceive('get')->andReturn([]); + $sessionSection->shouldReceive('set')->once(); + $sessionSection->shouldReceive('setExpiration')->once()->with('30 seconds')->andReturnUsing(function () use (&$expirationSet, $sessionSection) { + $expirationSet = true; + return $sessionSection; + }); + + $session = Mockery::mock(Http\Session::class); + $session->shouldReceive('getSection')->andReturn($sessionSection); + $session->shouldReceive('hasSection')->andReturn(true); + + $latte = Mockery::mock(Latte\Engine::class); + $latte->shouldIgnoreMissing(); + $templateFactory = Mockery::mock(Application\UI\TemplateFactory::class); + $templateFactory->shouldReceive('createTemplate')->andReturn(new Nette\Bridges\ApplicationLatte\DefaultTemplate($latte)); + + $presenter = new FlashPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + session: $session, + templateFactory: $templateFactory, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Flash', 'GET', ['action' => 'default'])); + + Assert::true($expirationSet); +}); + + +test('flash message accepts stdClass object', function () { + $sessionSection = Mockery::mock(Http\SessionSection::class); + $sessionSection->shouldReceive('get')->andReturn([]); + $sessionSection->shouldReceive('set')->once(); + + $session = Mockery::mock(Http\Session::class); + $session->shouldReceive('getSection')->andReturn($sessionSection); + $session->shouldReceive('hasSection')->andReturn(true); + + $latte = Mockery::mock(Latte\Engine::class); + $latte->shouldIgnoreMissing(); + $templateFactory = Mockery::mock(Application\UI\TemplateFactory::class); + $templateFactory->shouldReceive('createTemplate')->andReturn(new Nette\Bridges\ApplicationLatte\DefaultTemplate($latte)); + + $presenter = new TestPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + session: $session, + templateFactory: $templateFactory, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Test', 'GET', ['action' => 'default'])); + + $customFlash = (object) [ + 'message' => 'Custom message', + 'type' => 'warning', + 'custom' => 'data', + ]; + $flash = $presenter->flashMessage($customFlash); + + Assert::same($customFlash, $flash); + Assert::same('Custom message', $flash->message); + Assert::same('warning', $flash->type); + Assert::same('data', $flash->custom); +}); diff --git a/tests/UI/Presenter.lifecycle.phpt b/tests/UI/Presenter.lifecycle.phpt new file mode 100644 index 000000000..c3cc21ab4 --- /dev/null +++ b/tests/UI/Presenter.lifecycle.phpt @@ -0,0 +1,248 @@ +called[] = 'startup'; + } + + + public function actionDefault(): void + { + $this->called[] = 'actionDefault'; + } + + + protected function beforeRender(): void + { + parent::beforeRender(); + $this->called[] = 'beforeRender'; + } + + + public function renderDefault(): void + { + $this->called[] = 'renderDefault'; + $this->terminate(); + } + + + protected function afterRender(): void + { + parent::afterRender(); + $this->called[] = 'afterRender'; + } + + + protected function shutdown(Application\Response $response): void + { + parent::shutdown($response); + $this->called[] = 'shutdown'; + } +} + + +test('lifecycle methods are called in correct order', function () { + $latte = Mockery::mock(Latte\Engine::class); + $latte->shouldIgnoreMissing(); + $templateFactory = Mockery::mock(Application\UI\TemplateFactory::class); + $templateFactory->shouldReceive('createTemplate')->andReturn(new Nette\Bridges\ApplicationLatte\DefaultTemplate($latte)); + + $presenter = new LifecyclePresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + templateFactory: $templateFactory, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Lifecycle', 'GET', ['action' => 'default'])); + + Assert::same( + ['startup', 'actionDefault', 'beforeRender', 'renderDefault', 'shutdown'], + $presenter->called, + ); +}); + + +test('onStartup event is fired before startup', function () { + $eventCalled = false; + + $latte = Mockery::mock(Latte\Engine::class); + $latte->shouldIgnoreMissing(); + $templateFactory = Mockery::mock(Application\UI\TemplateFactory::class); + $templateFactory->shouldReceive('createTemplate')->andReturn(new Nette\Bridges\ApplicationLatte\DefaultTemplate($latte)); + + $presenter = new LifecyclePresenter; + $presenter->onStartup[] = function () use (&$eventCalled, $presenter) { + $eventCalled = true; + Assert::same([], $presenter->called); + }; + + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + templateFactory: $templateFactory, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Lifecycle', 'GET', ['action' => 'default'])); + + Assert::true($eventCalled); +}); + + +test('onRender event is fired after beforeRender', function () { + $eventCalled = false; + + $latte = Mockery::mock(Latte\Engine::class); + $latte->shouldIgnoreMissing(); + $templateFactory = Mockery::mock(Application\UI\TemplateFactory::class); + $templateFactory->shouldReceive('createTemplate')->andReturn(new Nette\Bridges\ApplicationLatte\DefaultTemplate($latte)); + + $presenter = new LifecyclePresenter; + $presenter->onRender[] = function () use (&$eventCalled, $presenter) { + $eventCalled = true; + Assert::same(['startup', 'actionDefault', 'beforeRender'], $presenter->called); + }; + + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + templateFactory: $templateFactory, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Lifecycle', 'GET', ['action' => 'default'])); + + Assert::true($eventCalled); +}); + + +test('onShutdown event is fired before shutdown', function () { + $eventCalled = false; + + $latte = Mockery::mock(Latte\Engine::class); + $latte->shouldIgnoreMissing(); + $templateFactory = Mockery::mock(Application\UI\TemplateFactory::class); + $templateFactory->shouldReceive('createTemplate')->andReturn(new Nette\Bridges\ApplicationLatte\DefaultTemplate($latte)); + + $presenter = new LifecyclePresenter; + $presenter->onShutdown[] = function () use (&$eventCalled, $presenter) { + $eventCalled = true; + Assert::same(['startup', 'actionDefault', 'beforeRender', 'renderDefault'], $presenter->called); + }; + + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + templateFactory: $templateFactory, + ); + $presenter->autoCanonicalize = false; + + $presenter->run(new Application\Request('Lifecycle', 'GET', ['action' => 'default'])); + + Assert::true($eventCalled); +}); + + +class SignalPresenter extends Application\UI\Presenter +{ + public array $called = []; + + + protected function startup(): void + { + parent::startup(); + $this->called[] = 'startup'; + } + + + public function handleRefresh(): void + { + $this->called[] = 'handleRefresh'; + } + + + public function actionDefault(): void + { + $this->called[] = 'actionDefault'; + } + + + public function renderDefault(): void + { + $this->called[] = 'renderDefault'; + $this->terminate(); + } +} + + +test('signal is called after action', function () { + $presenter = createPresenter(SignalPresenter::class, cookies: [Http\Helpers::StrictCookieName => 1]); + + $presenter->run(new Application\Request('Signal', 'GET', [ + 'action' => 'default', + 'do' => 'refresh', + ])); + + Assert::same(['startup', 'actionDefault', 'handleRefresh', 'renderDefault'], $presenter->called); +}); + + +class RedirectPresenter extends Application\UI\Presenter +{ + public array $called = []; + + + public function actionDefault(): void + { + $this->called[] = 'actionDefault'; + $this->redirect('other'); + } + + + public function actionOther(): void + { + $this->called[] = 'actionOther'; + } + + + public function renderOther(): void + { + $this->called[] = 'renderOther'; + $this->terminate(); + } +} + + +test('redirect terminates current lifecycle', function () { + $presenter = createPresenter(RedirectPresenter::class); + + $response = $presenter->run(new Application\Request('Redirect', 'GET', ['action' => 'default'])); + + Assert::type(Application\Responses\RedirectResponse::class, $response); + Assert::same(['actionDefault'], $presenter->called); + Assert::notContains('renderDefault', $presenter->called); + Assert::notContains('renderOther', $presenter->called); +}); diff --git a/tests/UI/Presenter.redirects.phpt b/tests/UI/Presenter.redirects.phpt new file mode 100644 index 000000000..dd6eaef32 --- /dev/null +++ b/tests/UI/Presenter.redirects.phpt @@ -0,0 +1,241 @@ +redirect('other', ['page' => 2]); + } + + + public function actionOther(): void + { + $this->terminate(); + } +} + + +test('redirect() creates RedirectResponse with 302 code', function () { + $presenter = createPresenter(RedirectPresenter::class); + + $response = $presenter->run(new Application\Request('Redirect', 'GET', ['action' => 'default'])); + + Assert::type(Application\Responses\RedirectResponse::class, $response); + Assert::same(Http\IResponse::S302_Found, $response->getCode()); +}); + + +class PermanentRedirectPresenter extends Application\UI\Presenter +{ + public function actionDefault(): void + { + $this->redirectPermanent('other'); + } + + + public function actionOther(): void + { + $this->terminate(); + } +} + + +test('redirectPermanent() creates RedirectResponse with 301 code', function () { + $presenter = createPresenter(PermanentRedirectPresenter::class); + + $response = $presenter->run(new Application\Request('PermanentRedirect', 'GET', ['action' => 'default'])); + + Assert::type(Application\Responses\RedirectResponse::class, $response); + Assert::same(Http\IResponse::S301_MovedPermanently, $response->getCode()); +}); + + +class ForwardPresenter extends Application\UI\Presenter +{ + public function actionDefault(): void + { + $this->forward('other'); + } + + + public function actionOther(): void + { + $this->terminate(); + } +} + + +test('forward() creates ForwardResponse', function () { + $presenter = createPresenter(ForwardPresenter::class); + + $response = $presenter->run(new Application\Request('Forward', 'GET', ['action' => 'default'])); + + Assert::type(Application\Responses\ForwardResponse::class, $response); +}); + + +class PersistentParamsPresenter extends Application\UI\Presenter +{ + #[Persistent] + public int $page = 1; + + #[Persistent] + public string $lang = 'en'; + + + public function actionDefault(): void + { + $this->redirect('other'); + } + + + public function actionOther(): void + { + $this->terminate(); + } +} + + +test('redirect() preserves persistent parameters', function () { + $presenter = new PersistentParamsPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript('http://localhost')), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + ); + $presenter->autoCanonicalize = false; + + $response = $presenter->run(new Application\Request('PersistentParams', 'GET', [ + 'action' => 'default', + 'page' => 5, + 'lang' => 'cs', + ])); + + Assert::type(Application\Responses\RedirectResponse::class, $response); + $url = $response->getUrl(); + Assert::contains('page=5', $url); + Assert::contains('lang=cs', $url); +}); + + +class OverrideParamsPresenter extends Application\UI\Presenter +{ + #[Persistent] + public int $page = 1; + + #[Persistent] + public string $lang = 'en'; + + + public function actionDefault(): void + { + $this->redirect('other', ['page' => 10, 'lang' => 'de']); + } + + + public function actionOther(): void + { + $this->terminate(); + } +} + + +test('redirect() can override persistent parameters', function () { + $presenter = new OverrideParamsPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript('http://localhost')), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + ); + $presenter->autoCanonicalize = false; + + $response = $presenter->run(new Application\Request('OverrideParams', 'GET', [ + 'action' => 'default', + 'page' => 5, + 'lang' => 'cs', + ])); + + Assert::type(Application\Responses\RedirectResponse::class, $response); + $url = $response->getUrl(); + Assert::contains('page=10', $url); + Assert::contains('lang=de', $url); +}); + + +class UrlRedirectPresenter extends Application\UI\Presenter +{ + public function actionDefault(): void + { + $this->redirectUrl('https://example.com/path'); + } +} + + +test('redirectUrl() creates RedirectResponse to external URL', function () { + $presenter = new UrlRedirectPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript('http://localhost')), + new Http\Response, + ); + $presenter->autoCanonicalize = false; + + $response = $presenter->run(new Application\Request('UrlRedirect', 'GET', ['action' => 'default'])); + + Assert::type(Application\Responses\RedirectResponse::class, $response); + Assert::same('https://example.com/path', $response->getUrl()); +}); + + +class PostRedirectPresenter extends Application\UI\Presenter +{ + public function actionDefault(): void + { + if ($this->getRequest()->getMethod() === 'POST') { + $this->redirect('success'); + } + } + + + public function actionSuccess(): void + { + $this->terminate(); + } + + + public function renderDefault(): void + { + $this->terminate(); + } +} + + +test('POST request redirect uses 302 code', function () { + $presenter = createPresenter(PostRedirectPresenter::class, post: ['data' => 'value']); + + $response = $presenter->run(new Application\Request('PostRedirect', 'POST', ['action' => 'default'])); + + Assert::type(Application\Responses\RedirectResponse::class, $response); + Assert::same(Http\IResponse::S302_Found, $response->getCode()); +}); diff --git a/tests/UI/Presenter.storeRequest().phpt b/tests/UI/Presenter.storeRequest().phpt index 50ca0cd76..79b6ff802 100644 --- a/tests/UI/Presenter.storeRequest().phpt +++ b/tests/UI/Presenter.storeRequest().phpt @@ -128,3 +128,140 @@ test('request storage without user context', function () { Assert::same($key, $storedKey); Assert::same([null, $applicationRequest], $storedValue); }); + + +test('restoreRequest() restores stored request', function () { + $storedData = [ + 'test_id', + new Application\Request('Test', 'POST', ['action' => 'edit', 'id' => 5], ['name' => 'John']), + ]; + + $sessionSection = Mockery::mock(Http\SessionSection::class); + $sessionSection->shouldReceive('get')->with('abc123')->andReturn($storedData); + $sessionSection->shouldReceive('remove')->with('abc123')->once(); + + $session = Mockery::mock(Http\Session::class); + $session->shouldReceive('getSection')->andReturn($sessionSection); + + $user = Mockery::mock(Nette\Security\User::class); + $user->shouldReceive('getId')->andReturn('test_id'); + + $presenter = new TestPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + $session, + $user, + ); + + $presenter->autoCanonicalize = false; + $presenter->run(new Application\Request('Test', 'GET', ['action' => 'default'])); + + Assert::exception( + fn() => $presenter->restoreRequest('abc123'), + Application\AbortException::class, + ); + + // Verified that get/remove were called (via Mockery expectations) +}); + + +test('restoreRequest() ignores request with wrong user', function () { + $storedData = [ + 'different_user', + new Application\Request('Test', 'POST', ['action' => 'edit']), + ]; + + $sessionSection = Mockery::mock(Http\SessionSection::class); + $sessionSection->shouldReceive('get')->with('abc123')->andReturn($storedData); + $sessionSection->shouldReceive('remove')->never(); + + $session = Mockery::mock(Http\Session::class); + $session->shouldReceive('getSection')->andReturn($sessionSection); + + $user = Mockery::mock(Nette\Security\User::class); + $user->shouldReceive('getId')->andReturn('test_id'); + + $presenter = new TestPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + null, + new Application\Routers\SimpleRouter, + $session, + $user, + ); + + $presenter->autoCanonicalize = false; + $presenter->run(new Application\Request('Test', 'GET', ['action' => 'default'])); + + $presenter->restoreRequest('abc123'); + + // Request should not be restored due to user mismatch +}); + + +test('restoreRequest() handles missing request', function () { + $sessionSection = Mockery::mock(Http\SessionSection::class); + $sessionSection->shouldReceive('get')->with('nonexistent')->andReturn(null); + $sessionSection->shouldReceive('remove')->never(); + + $session = Mockery::mock(Http\Session::class); + $session->shouldReceive('getSection')->andReturn($sessionSection); + + $presenter = new TestPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + null, + new Application\Routers\SimpleRouter, + $session, + ); + + $presenter->autoCanonicalize = false; + $presenter->run(new Application\Request('Test', 'GET', ['action' => 'default'])); + + $presenter->restoreRequest('nonexistent'); + + // Should handle gracefully without error +}); + + +test('restoreRequest() accepts request without user when stored without user', function () { + $storedData = [ + null, + new Application\Request('Test', 'POST', ['action' => 'edit']), + ]; + + $sessionSection = Mockery::mock(Http\SessionSection::class); + $sessionSection->shouldReceive('get')->with('abc123')->andReturn($storedData); + $sessionSection->shouldReceive('remove')->with('abc123')->once(); + + $session = Mockery::mock(Http\Session::class); + $session->shouldReceive('getSection')->andReturn($sessionSection); + + $user = Mockery::mock(Nette\Security\User::class); + $user->shouldReceive('getId')->andReturn('test_id'); + + $presenter = new TestPresenter; + $presenter->injectPrimary( + new Http\Request(new Http\UrlScript), + new Http\Response, + new Application\PresenterFactory, + new Application\Routers\SimpleRouter, + $session, + $user, + ); + + $presenter->autoCanonicalize = false; + $presenter->run(new Application\Request('Test', 'GET', ['action' => 'default'])); + + Assert::exception( + fn() => $presenter->restoreRequest('abc123'), + Application\AbortException::class, + ); + + // Request stored without user (null) should be restorable by any user +}); diff --git a/tests/UI/Requires.combinations.phpt b/tests/UI/Requires.combinations.phpt new file mode 100644 index 000000000..2bb4bba18 --- /dev/null +++ b/tests/UI/Requires.combinations.phpt @@ -0,0 +1,255 @@ +terminate(); + } +} + + +test('POST + AJAX requirement both satisfied', function () { + $presenter = createPresenter( + PostAjaxPresenter::class, + method: Http\Request::Post, + headers: ['X-Requested-With' => 'XMLHttpRequest'], + ); + + Assert::noError( + fn() => $presenter->run(new Application\Request('Test', Http\Request::Post)), + ); +}); + + +test('POST requirement satisfied but AJAX missing', function () { + $presenter = createPresenter( + PostAjaxPresenter::class, + method: Http\Request::Post, + ); + + Assert::exception( + fn() => $presenter->run(new Application\Request('Test', Http\Request::Post)), + Application\BadRequestException::class, + 'AJAX request is required by PostAjaxPresenter', + ); +}); + + +test('AJAX requirement satisfied but wrong HTTP method', function () { + $presenter = createPresenter( + PostAjaxPresenter::class, + headers: ['X-Requested-With' => 'XMLHttpRequest'], + ); + + Assert::exception( + fn() => $presenter->run(new Application\Request('Test', Http\Request::Get)), + Application\BadRequestException::class, + 'Method GET is not allowed by PostAjaxPresenter', + ); +}); + + +#[Requires(sameOrigin: true, methods: 'POST')] +class CsrfPostPresenter extends Application\UI\Presenter +{ + public function actionDefault(): never + { + $this->terminate(); + } +} + + +test('sameOrigin + POST both satisfied', function () { + $presenter = createPresenter( + CsrfPostPresenter::class, + method: Http\Request::Post, + cookies: [Http\Helpers::StrictCookieName => 1], + ); + + Assert::noError( + fn() => $presenter->run(new Application\Request('Test', Http\Request::Post)), + ); +}); + + +test('sameOrigin satisfied but wrong method', function () { + $presenter = createPresenter( + CsrfPostPresenter::class, + cookies: [Http\Helpers::StrictCookieName => 1], + ); + + Assert::exception( + fn() => $presenter->run(new Application\Request('Test', Http\Request::Get)), + Application\BadRequestException::class, + 'Method GET is not allowed by CsrfPostPresenter', + ); +}); + + +test('POST satisfied but sameOrigin violated', function () { + $presenter = createPresenter( + CsrfPostPresenter::class, + method: Http\Request::Post, + ); + + // Without the strict cookie, request is considered cross-origin and redirected + $response = $presenter->run(new Application\Request('Test', Http\Request::Post)); + Assert::type(Application\Responses\RedirectResponse::class, $response); +}); + + +class MultiRequiresPresenter extends Application\UI\Presenter +{ + #[Requires(methods: 'POST')] + #[Requires(ajax: true)] + public function actionDefault(): never + { + $this->terminate(); + } +} + + +test('multiple Requires attributes on same action', function () { + $presenter = createPresenter( + MultiRequiresPresenter::class, + method: Http\Request::Post, + headers: ['X-Requested-With' => 'XMLHttpRequest'], + ); + + Assert::noError( + fn() => $presenter->run(new Application\Request('Test', Http\Request::Post, ['action' => 'default'])), + ); +}); + + +test('multiple Requires - first satisfied, second violated', function () { + $presenter = createPresenter( + MultiRequiresPresenter::class, + method: Http\Request::Post, + ); + + Assert::exception( + fn() => $presenter->run(new Application\Request('Test', Http\Request::Post, ['action' => 'default'])), + Application\BadRequestException::class, + 'AJAX request is required by MultiRequiresPresenter::actionDefault()', + ); +}); + + +test('multiple Requires - second satisfied, first violated', function () { + $presenter = createPresenter( + MultiRequiresPresenter::class, + headers: ['X-Requested-With' => 'XMLHttpRequest'], + ); + + Assert::exception( + fn() => $presenter->run(new Application\Request('Test', Http\Request::Get, ['action' => 'default'])), + Application\BadRequestException::class, + 'Method GET is not allowed by MultiRequiresPresenter::actionDefault()', + ); +}); + + +#[Requires(ajax: true)] +class AjaxPresenterWithAction extends Application\UI\Presenter +{ + #[Requires(methods: 'POST')] + public function actionEdit(): never + { + $this->terminate(); + } + + + public function actionDefault(): never + { + $this->terminate(); + } +} + + +test('class-level + method-level requirements combine', function () { + $presenter = createPresenter( + AjaxPresenterWithAction::class, + method: Http\Request::Post, + headers: ['X-Requested-With' => 'XMLHttpRequest'], + ); + + Assert::noError( + fn() => $presenter->run(new Application\Request('Test', Http\Request::Post, ['action' => 'edit'])), + ); +}); + + +test('class-level requirement satisfied, method-level violated', function () { + $presenter = createPresenter( + AjaxPresenterWithAction::class, + headers: ['X-Requested-With' => 'XMLHttpRequest'], + ); + + Assert::exception( + fn() => $presenter->run(new Application\Request('Test', Http\Request::Get, ['action' => 'edit'])), + Application\BadRequestException::class, + 'Method GET is not allowed by AjaxPresenterWithAction::actionEdit()', + ); +}); + + +test('method-level requirement satisfied, class-level violated', function () { + $presenter = createPresenter( + AjaxPresenterWithAction::class, + method: Http\Request::Post, + ); + + Assert::exception( + fn() => $presenter->run(new Application\Request('Test', Http\Request::Post, ['action' => 'edit'])), + Application\BadRequestException::class, + 'AJAX request is required by AjaxPresenterWithAction', + ); +}); + + +#[Requires(forward: true)] +class ForwardPresenter extends Application\UI\Presenter +{ + public function actionDefault(): never + { + $this->terminate(); + } +} + + +test('forward requirement satisfied', function () { + $presenter = createPresenter(ForwardPresenter::class); + + Assert::noError( + fn() => $presenter->run(new Application\Request('Test', Application\Request::FORWARD)), + ); +}); + + +test('forward requirement violated', function () { + $presenter = createPresenter(ForwardPresenter::class); + + Assert::exception( + fn() => $presenter->run(new Application\Request('Test', Http\Request::Get)), + Application\BadRequestException::class, + 'Forwarded request is required by ForwardPresenter', + ); +}); From 60e7d2a143c1b770156448657df6841ab1cceebf Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 8 Jan 2026 23:39:11 +0100 Subject: [PATCH 5/6] improved phpDoc --- src/Application/Helpers.php | 2 +- src/Application/PresenterFactory.php | 4 +++- src/Application/Request.php | 2 +- src/Application/Responses/CallbackResponse.php | 2 +- src/Application/Routers/CliRouter.php | 2 ++ src/Application/Routers/Route.php | 6 +++++- src/Application/Routers/RouteList.php | 3 +++ src/Application/UI/AccessPolicy.php | 3 ++- src/Application/UI/Component.php | 6 ++++++ src/Application/UI/ComponentReflection.php | 7 +++++-- src/Application/UI/Control.php | 11 ++++++++++- src/Application/UI/Link.php | 2 ++ src/Application/UI/ParameterConverter.php | 1 + src/Application/UI/Presenter.php | 12 ++++++++++++ src/Bridges/ApplicationDI/ApplicationExtension.php | 2 ++ .../ApplicationDI/PresenterFactoryCallback.php | 1 + src/Bridges/ApplicationLatte/Template.php | 3 +++ src/Bridges/ApplicationLatte/TemplateFactory.php | 3 ++- src/Bridges/ApplicationTracy/RoutingPanel.php | 4 ++++ 19 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/Application/Helpers.php b/src/Application/Helpers.php index 51b266cea..ca6a4e869 100644 --- a/src/Application/Helpers.php +++ b/src/Application/Helpers.php @@ -34,7 +34,7 @@ public static function splitName(string $name): array /** - * return string[] + * @return array */ public static function getClassesAndTraits(string $class): array { diff --git a/src/Application/PresenterFactory.php b/src/Application/PresenterFactory.php index 063ff2ccb..1705acfad 100644 --- a/src/Application/PresenterFactory.php +++ b/src/Application/PresenterFactory.php @@ -18,7 +18,7 @@ */ class PresenterFactory implements IPresenterFactory { - /** @var array[] of module => splited mask */ + /** @var array module => splited mask */ private array $mapping = [ '*' => ['', '*Module\\', '*Presenter'], 'Nette' => ['NetteModule\\', '*\\', '*Presenter'], @@ -78,6 +78,7 @@ public function getPresenterClass(string &$name): string /** * Sets mapping as pairs [module => mask] + * @param array $mapping */ public function setMapping(array $mapping): static { @@ -123,6 +124,7 @@ public function formatPresenterClass(string $presenter): string /** * Sets pairs [alias => destination] + * @param array $aliases */ public function setAliases(array $aliases): static { diff --git a/src/Application/Request.php b/src/Application/Request.php index 72f46f181..8b2ebed33 100644 --- a/src/Application/Request.php +++ b/src/Application/Request.php @@ -20,7 +20,7 @@ * @property array $parameters * @property array $post * @property array $files - * @property string|null $method + * @property ?string $method */ final class Request { diff --git a/src/Application/Responses/CallbackResponse.php b/src/Application/Responses/CallbackResponse.php index 91a2cc74c..93e44d0a4 100644 --- a/src/Application/Responses/CallbackResponse.php +++ b/src/Application/Responses/CallbackResponse.php @@ -22,7 +22,7 @@ final class CallbackResponse implements Nette\Application\Response /** - * @param callable(Nette\Http\IRequest, Nette\Http\Response): void $callback + * @param callable(Nette\Http\IRequest, Nette\Http\IResponse): void $callback */ public function __construct(callable $callback) { diff --git a/src/Application/Routers/CliRouter.php b/src/Application/Routers/CliRouter.php index 589fc898b..c02cfb8e3 100644 --- a/src/Application/Routers/CliRouter.php +++ b/src/Application/Routers/CliRouter.php @@ -22,6 +22,7 @@ final class CliRouter implements Nette\Routing\Router public function __construct( + /** @var array */ private readonly array $defaults = [], ) { } @@ -99,6 +100,7 @@ public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?stri /** * Returns default values. + * @return array */ public function getDefaults(): array { diff --git a/src/Application/Routers/Route.php b/src/Application/Routers/Route.php index 3e7b5410a..2932d0129 100644 --- a/src/Application/Routers/Route.php +++ b/src/Application/Routers/Route.php @@ -98,6 +98,7 @@ public function match(Nette\Http\IRequest $httpRequest): ?array /** * Constructs absolute URL from array. + * @param array $params */ public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?string { @@ -121,7 +122,10 @@ public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?stri } - /** @internal */ + /** + * @return array + * @internal + */ public function getConstantParameters(): array { $res = parent::getConstantParameters(); diff --git a/src/Application/Routers/RouteList.php b/src/Application/Routers/RouteList.php index 74b25363a..3a53415da 100644 --- a/src/Application/Routers/RouteList.php +++ b/src/Application/Routers/RouteList.php @@ -33,6 +33,8 @@ public function __construct(?string $module = null) /** * Support for modules. + * @param array $params + * @return ?array */ protected function completeParameters(array $params): ?array { @@ -47,6 +49,7 @@ protected function completeParameters(array $params): ?array /** * Constructs absolute URL from array. + * @param array $params */ public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?string { diff --git a/src/Application/UI/AccessPolicy.php b/src/Application/UI/AccessPolicy.php index 0d13df955..b478d3ed6 100644 --- a/src/Application/UI/AccessPolicy.php +++ b/src/Application/UI/AccessPolicy.php @@ -63,7 +63,8 @@ private function getAttributes(): array /** - * @param Requires[] $attrs + * @param Attributes\Requires[] $attrs + * @return Attributes\Requires[] */ private function applyInternalRules(array $attrs, Component $component): array { diff --git a/src/Application/UI/Component.php b/src/Application/UI/Component.php index e14ac4361..a649bb1ad 100644 --- a/src/Application/UI/Component.php +++ b/src/Application/UI/Component.php @@ -29,6 +29,8 @@ abstract class Component extends Nette\ComponentModel\Container implements Signa /** @var array Occurs when component is attached to presenter */ public array $onAnchor = []; + + /** @var array */ protected array $params = []; @@ -99,6 +101,7 @@ protected function validateParent(Nette\ComponentModel\IContainer $parent): void /** * Calls public method if exists. + * @param array $params */ protected function tryCall(string $method, array $params): bool { @@ -146,6 +149,7 @@ public static function getReflection(): ComponentReflection /** * Loads state information. + * @param array $params */ public function loadState(array $params): void { @@ -174,6 +178,7 @@ public function loadState(array $params): void /** * Saves state information for next request. + * @param array $params */ public function saveState(array &$params): void { @@ -235,6 +240,7 @@ final public function getParameter(string $name): mixed /** * Returns component parameters. + * @return array */ final public function getParameters(): array { diff --git a/src/Application/UI/ComponentReflection.php b/src/Application/UI/ComponentReflection.php index e8454ff4e..55b945964 100644 --- a/src/Application/UI/ComponentReflection.php +++ b/src/Application/UI/ComponentReflection.php @@ -30,7 +30,7 @@ final class ComponentReflection extends \ReflectionClass /** * Returns array of class properties that are public and have attribute #[Persistent] or #[Parameter] or annotation @persistent. - * @return array + * @return array */ public function getParameters(): array { @@ -78,7 +78,7 @@ public function getParameters(): array /** * Returns array of persistent properties. They are public and have attribute #[Persistent] or annotation @persistent. - * @return array + * @return array */ public function getPersistentParams(): array { @@ -115,6 +115,9 @@ public function getPersistentComponents(): array } + /** + * @return string[] names of public properties with #[TemplateVariable] attribute + */ public function getTemplateVariables(Control $control): array { $res = []; diff --git a/src/Application/UI/Control.php b/src/Application/UI/Control.php index 24484dc3d..a3168e827 100644 --- a/src/Application/UI/Control.php +++ b/src/Application/UI/Control.php @@ -46,6 +46,11 @@ final public function getTemplate(): Template } + /** + * @template T of Template + * @param ?class-string $class + * @return ($class is null ? Template : T) + */ protected function createTemplate(?string $class = null): Template { $class ??= $this->formatTemplateClass(); @@ -54,13 +59,17 @@ protected function createTemplate(?string $class = null): Template } + /** @return ?class-string