diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5edbad1..5bc7d78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [ main, 5.x-dev ] pull_request: - branches: [ main ] + branches: [ main, 5.x-dev ] env: PHP_EXTENSIONS: intl, pdo_sqlite @@ -30,5 +30,53 @@ jobs: - name: Run PHPStan run: vendor/bin/phpstan analyse --no-progress --error-format=github + - name: Show PHPCS installed standards + run: vendor/bin/phpcs -i || true + + - name: Ensure PHPCS sees Cake & Slevomat standards + run: | + vendor/bin/phpcs --config-set installed_paths \ + vendor/cakephp/cakephp-codesniffer,vendor/slevomat/coding-standard + vendor/bin/phpcs -i + - name: Run PHPCS - run: vendor/bin/phpcs \ No newline at end of file + run: vendor/bin/phpcs + + tests: + name: PHP ${{ matrix.php }} • PHPUnit + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - php: '8.2' + experimental: false + - php: '8.3' + experimental: false + - php: '8.4' + experimental: true # allow failures if ecosystem lags + continue-on-error: ${{ matrix.experimental }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: ${{ env.PHP_EXTENSIONS }} + ini-values: | + memory_limit=512M + coverage: none + + - name: Validate composer.json + run: composer validate --strict + + - name: Install dependencies (cached) + uses: ramsey/composer-install@v3 + with: + composer-options: --no-interaction --no-progress + + - name: Run PHPUnit + run: vendor/bin/phpunit diff --git a/README.md b/README.md index ad52220..70b15ec 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ document.body.addEventListener('htmx:configRequest', (event) => { ## Rendering blocks and OOB Swap The `setBlock()` function allows you to render a specific block while removing other blocks that might be rendered. This is particularly useful when you need to update only a portion of your view. +Calling `setBlock(null)` clears any selection. + ```php $this->Htmx->setBlock('userTable'); ``` @@ -130,9 +132,15 @@ The `addBlocks()` function allows you to add multiple blocks to the list of bloc $this->Htmx->addBlocks(['userTable', 'pagination']); $this->Htmx->addBlocks(['userTable', 'pagination'], true); // Appends the blocks to the existing array. ``` +> **Note:** `addBlocks()` appends by default. Pass `false` as the second argument to replace: +> +> ```php +> $this->Htmx->addBlocks(['usersTable', 'pagination']); // append (default) +> $this->Htmx->addBlocks(['onlyThis'], false); // replace +> ``` ### OOB Swap -Htmx supports updating multiple targets by returning multiple partial responses with [`hx-swap-oop`](https://htmx.org/docs/#oob_swaps). +Htmx supports updating multiple targets by returning multiple partial responses with [`hx-swap-oob`](https://htmx.org/docs/#oob_swaps). See the example `Users index search functionality with pagination update` Note if you are working with tables like in the example. You might need to add ```javascript @@ -142,6 +150,17 @@ Note if you are working with tables like in the example. You might need to add ``` In your template or layout. + +### Clearing Blocks +You can clear the current block selection in two equivalent ways: +```php +// Explicitly clear any selection +$this->Htmx->clearBlocks(); + +// Or, using setBlock(null) +$this->Htmx->setBlock(null); +``` + ## Examples ### Users index search functionality diff --git a/composer.json b/composer.json index cdf0301..695bd1e 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,9 @@ "require-dev": { "phpunit/phpunit": "^10.1", "phpstan/phpstan": "^2.1", - "cakephp/cakephp-codesniffer": "^5.2" + "cakephp/cakephp-codesniffer": "^5.2", + "slevomat/coding-standard": "^8.15", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" }, "autoload": { "psr-4": { @@ -19,8 +21,7 @@ }, "autoload-dev": { "psr-4": { - "CakeHtmx\\Test\\": "tests/", - "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + "CakeHtmx\\Test\\": "tests/" } }, "config": { @@ -29,6 +30,7 @@ } }, "scripts": { + "test": "phpunit", "stan": "phpstan analyse", "cs-check": "phpcs --colors -p -s", "cs-fix": "phpcbf --colors -p -s" diff --git a/src/Controller/Component/HtmxComponent.php b/src/Controller/Component/HtmxComponent.php index 95019df..a586b83 100644 --- a/src/Controller/Component/HtmxComponent.php +++ b/src/Controller/Component/HtmxComponent.php @@ -91,31 +91,33 @@ public function beforeRender(Event $event): void */ public function afterRender(Event $event): void { - if (!empty($this->blocks)) { - /** @var \Cake\View\View $view */ - $view = $event->getSubject(); - // empty the content and replace with the ones we want - $view->assign('content', ''); - - $first = true; - foreach ($this->blocks as $block) { - if ($view->exists($block)) { - $fetchBlock = $view->fetch($block); - - if (!$first) { - $fetchBlock = preg_replace( - '/(<[^\/][^>]*)(>)/', - '$1 hx-swap-oob="innerHTML"$2', - $fetchBlock, - 1, - ); - } - - $view->append('content', $fetchBlock); - - if ($first) { - $first = false; - } + if (empty($this->blocks)) { + return; + } + + /** @var \Cake\View\View $view */ + $view = $event->getSubject(); + // empty the content and replace with the ones we want + $view->assign('content', ''); + + $first = true; + foreach ($this->blocks as $block) { + if ($view->exists($block)) { + $fetchBlock = $view->fetch($block); + + if (!$first) { + $fetchBlock = preg_replace( + '/(<[^\/][^>]*)(>)/', + '$1 hx-swap-oob="innerHTML"$2', + $fetchBlock, + 1, + ); + } + + $view->append('content', $fetchBlock); + + if ($first) { + $first = false; } } } @@ -392,12 +394,13 @@ private function encodeTriggers(array $triggers): string /** * Set a specific block to render * Removes other blocks that might be rendered + * Passing `null` will clear all blocks. * * @param string|null $block Name of the block */ public function setBlock(?string $block): static { - $this->blocks = [$block]; + $this->blocks = !empty($block) ? [$block] : []; return $this; } @@ -420,8 +423,14 @@ public function addBlock(string $block): static * @param array $blocks List of block names to render * @param bool $append Whether to append the blocks or replace existing ones */ - public function addBlocks(array $blocks, bool $append = false): static + public function addBlocks(array $blocks, bool $append = true): static { + // Make sure no empty blocks + $blocks = array_values(array_filter( + $blocks, + static fn($b) => is_string($b) && $b !== '', + )); + if ($append) { $this->blocks = array_merge($this->blocks, $blocks); } else { @@ -440,4 +449,16 @@ public function getBlocks(): ?array { return $this->blocks; } + + /** + * Clear all blocks so none will be rendered. + * + * @return static + */ + public function clearBlocks(): static + { + $this->blocks = []; + + return $this; + } } diff --git a/tests/TestCase/Controller/Component/HtmxComponentTest.php b/tests/TestCase/Controller/Component/HtmxComponentTest.php new file mode 100644 index 0000000..ec6ac3e --- /dev/null +++ b/tests/TestCase/Controller/Component/HtmxComponentTest.php @@ -0,0 +1,494 @@ +Controller = new Controller($request); + $this->Controller = $this->Controller->setResponse(new Response()); + + $registry = new ComponentRegistry($this->Controller); + $this->Htmx = new HtmxComponent($registry); + } + + /** + * tearDown method. + * + * @return void + */ + protected function tearDown(): void + { + unset($this->Htmx, $this->Controller); + parent::tearDown(); + } + + /** + * beforeRender(): on a non-htmx request, no headers are written + * even if triggers have been added. + * + * @return void + */ + public function testBeforeRenderNonHtmxDoesNotWriteHeaders(): void + { + // Attach detector to current (non-htmx) request + $req = $this->Controller->getRequest(); + $req->addDetector('htmx', function ($r) { + return filter_var($r->getHeaderLine('HX-Request'), FILTER_VALIDATE_BOOLEAN); + }); + $this->Controller = $this->Controller->setRequest($req); + + // Sanity check: not htmx + $this->assertFalse($this->Controller->getRequest()->is('htmx')); + + // Add a trigger; since not htmx, prepare() should not run + $this->Htmx->addTrigger('alpha'); + + $this->Htmx->beforeRender(new Event('Controller.beforeRender', $this->Controller)); + $this->assertSame('', $this->Controller->getResponse()->getHeaderLine('HX-Trigger')); + } + + /** + * beforeRender(): when request is htmx, accumulated triggers are applied. + * + * @return void + */ + public function testBeforeRenderAppliesTriggersWhenHtmx(): void + { + // Use stubbed request that reports is('htmx') === true + $this->Controller = $this->Controller->setRequest($this->makeHtmxTrueRequest()); + + // Add triggers (component retains them) + $this->Htmx->addTrigger('alpha')->addTrigger('beta'); + + // Run the hook + $this->Htmx->beforeRender(new Event('Controller.beforeRender', $this->Controller)); + + // Assert headers were written by prepare() + $this->assertSame('alpha,beta', $this->Controller->getResponse()->getHeaderLine('HX-Trigger')); + } + + /** + * Test afterRender(): renders chosen blocks and adds hx-swap-oob to subsequent ones. + * + * @return void + * @link \CakeHtmx\Controller\Component\HtmxComponent::afterRender() + */ + public function testAfterRenderBlocksAndOobSwap(): void + { + // Use a partial mock to force exists() true for our block names. + $view = $this->getMockBuilder(View::class) + ->setConstructorArgs([$this->Controller->getRequest()]) + ->onlyMethods(['exists']) + ->getMock(); + + $view->method('exists')->willReturnCallback(function (string $name): bool { + return in_array($name, ['first', 'second'], true); + }); + + // Define two blocks + $view->start('first'); + echo '
A
'; + $view->end(); + + $view->start('second'); + echo '
B
'; + $view->end(); + + $this->Htmx->addBlocks(['first', 'second']); + + // Prime content then ensure it's cleared/replaced + $view->assign('content', 'ORIGINAL'); + + $this->Htmx->afterRender(new Event('View.afterRender', $view)); + $content = $view->fetch('content'); + + $this->assertStringContainsString('
A
', $content); + $this->assertStringContainsString( + '
B
', + $content, + ); + $this->assertStringNotContainsString('ORIGINAL', $content); + } + + /** + * Test header getter helpers. + * + * @return void + */ + public function testHeaderGetters(): void + { + $req = $this->Controller->getRequest() + ->withHeader('HX-Current-Url', 'https://example.test/page') + ->withHeader('HX-Prompt', 'yes') + ->withHeader('HX-Target', 'list') + ->withHeader('HX-Trigger-Name', 'delete') + ->withHeader('HX-Trigger', 'btn-42'); + $this->Controller = $this->Controller->setRequest($req); + + $registry = new ComponentRegistry($this->Controller); + $this->Htmx = new HtmxComponent($registry); + + $this->assertSame('https://example.test/page', $this->Htmx->getCurrentUrl()); + $this->assertSame('yes', $this->Htmx->getPromptResponse()); + $this->assertSame('list', $this->Htmx->getTarget()); + $this->assertSame('delete', $this->Htmx->getTriggerName()); + $this->assertSame('btn-42', $this->Htmx->getTriggerId()); + } + + /** + * Test simple response header mutators. + * + * @return void + */ + public function testSimpleHeaderMutators(): void + { + $this->Htmx->location('/go'); + $this->assertSame('/go', $this->Controller->getResponse()->getHeaderLine('HX-Location')); + + $this->Htmx->pushUrl('/new'); + $this->assertSame('/new', $this->Controller->getResponse()->getHeaderLine('HX-Push-Url')); + + $this->Htmx->replaceUrl('/replace'); + $this->assertSame('/replace', $this->Controller->getResponse()->getHeaderLine('HX-Replace-Url')); + + $this->Htmx->reswap('none'); + $this->assertSame('none', $this->Controller->getResponse()->getHeaderLine('HX-Reswap')); + + $this->Htmx->retarget('#modal'); + $this->assertSame('#modal', $this->Controller->getResponse()->getHeaderLine('HX-Retarget')); + } + + /** + * prepare(): no headers when no triggers exist. + * + * @return void + * @link \CakeHtmx\Controller\Component\HtmxComponent::prepare() + */ + public function testPrepareNoTriggersNoHeaders(): void + { + $this->Htmx->prepare(); + $res = $this->Controller->getResponse(); + + $this->assertSame('', $res->getHeaderLine('HX-Trigger')); + $this->assertSame('', $res->getHeaderLine('HX-Trigger-After-Settle')); + $this->assertSame('', $res->getHeaderLine('HX-Trigger-After-Swap')); + } + + /** + * prepare(): CSV encoding when all bodies are null. + * + * @return void + * @link \CakeHtmx\Controller\Component\HtmxComponent::prepare() + */ + public function testPrepareWithCsvEncodedTriggers(): void + { + $this->Htmx + ->addTrigger('alpha') + ->addTrigger('beta') + ->addTriggerAfterSettle('gamma') + ->addTriggerAfterSwap('delta'); + + $this->Htmx->prepare(); + $res = $this->Controller->getResponse(); + + $this->assertSame('alpha,beta', $res->getHeaderLine('HX-Trigger')); + $this->assertSame('gamma', $res->getHeaderLine('HX-Trigger-After-Settle')); + $this->assertSame('delta', $res->getHeaderLine('HX-Trigger-After-Swap')); + } + + /** + * prepare(): JSON encoding when any body is non-null. + * + * @return void + * @link \CakeHtmx\Controller\Component\HtmxComponent::prepare() + */ + public function testPrepareWithJsonEncodedTriggers(): void + { + // Reset response to avoid header accumulation + $this->Controller = $this->Controller->setResponse(new Response()); + $registry = new ComponentRegistry($this->Controller); + $this->Htmx = new HtmxComponent($registry); + + $this->Htmx + ->addTrigger('saved', ['id' => 7]) + ->addTrigger('notify', 'ok') // multiple keys in JSON + ->addTriggerAfterSettle('notice', 'ok') + ->addTriggerAfterSwap('ping', ['a' => 1, 'b' => 2]); + + $this->Htmx->prepare(); + $res = $this->Controller->getResponse(); + + $this->assertSame('{"saved":{"id":7},"notify":"ok"}', $res->getHeaderLine('HX-Trigger')); + $this->assertSame('{"notice":"ok"}', $res->getHeaderLine('HX-Trigger-After-Settle')); + $this->assertSame('{"ping":{"a":1,"b":2}}', $res->getHeaderLine('HX-Trigger-After-Swap')); + } + + /** + * Test redirect() sets HX-Redirect and 200 status. + * + * @return void + * @link \CakeHtmx\Controller\Component\HtmxComponent::redirect() + */ + public function testRedirect(): void + { + $this->Htmx->redirect('/somewhere'); + $resp = $this->Controller->getResponse(); + + $this->assertSame('/somewhere', $resp->getHeaderLine('HX-Redirect')); + $this->assertSame(200, $resp->getStatusCode()); + } + + /** + * Test clientRefresh() sets HX-Refresh and 200 status. + * + * @return void + * @link \CakeHtmx\Controller\Component\HtmxComponent::clientRefresh() + */ + public function testClientRefresh(): void + { + $this->Htmx->clientRefresh(); + $resp = $this->Controller->getResponse(); + + $this->assertSame('true', $resp->getHeaderLine('HX-Refresh')); + $this->assertSame(200, $resp->getStatusCode()); + } + + /** + * Test stopPolling() sets 286 status, optional headers, and body. + * + * @return void + * @link \CakeHtmx\Controller\Component\HtmxComponent::stopPolling() + */ + public function testStopPolling(): void + { + $resp = $this->Htmx->stopPolling('done', ['X-Test' => '1']); + + $this->assertInstanceOf(Response::class, $resp); + /** @var \Cake\Http\Response $resp */ + $this->assertSame(286, $resp->getStatusCode()); + $this->assertSame('1', $resp->getHeaderLine('X-Test')); + $this->assertSame('done', (string)$resp->getBody()); + } + + /** + * Test block list helpers: setBlock(), addBlock(), addBlocks(), getBlocks(). + * + * @return void + * @link \CakeHtmx\Controller\Component\HtmxComponent::setBlock() + * @link \CakeHtmx\Controller\Component\HtmxComponent::addBlock() + * @link \CakeHtmx\Controller\Component\HtmxComponent::addBlocks() + * @link \CakeHtmx\Controller\Component\HtmxComponent::getBlocks() + */ + public function testBlockHelpers(): void + { + $this->Htmx->setBlock('usersTable'); + $this->assertSame(['usersTable'], $this->Htmx->getBlocks()); + + $this->Htmx->addBlock('pagination'); + $this->assertSame(['usersTable', 'pagination'], $this->Htmx->getBlocks()); + + // Replace with new list + $this->Htmx->addBlocks(['a', 'b'], false); + $this->assertSame(['a', 'b'], $this->Htmx->getBlocks()); + + // Append to existing + $this->Htmx->addBlocks(['c'], true); + $this->assertSame(['a', 'b', 'c'], $this->Htmx->getBlocks()); + } + + /** + * addBlocks(): default behavior appends to existing blocks (append = true). + * + * @return void + */ + public function testAddBlocksDefaultAppends(): void + { + // Seed with one block + $this->Htmx->setBlock('initial'); + $this->assertSame(['initial'], $this->Htmx->getBlocks()); + + // Call addBlocks() WITHOUT the $append argument -> should append + $this->Htmx->addBlocks(['a', 'b']); // default append=true + $this->assertSame(['initial', 'a', 'b'], $this->Htmx->getBlocks()); + + // Another default call should keep appending + $this->Htmx->addBlocks(['c']); + $this->assertSame(['initial', 'a', 'b', 'c'], $this->Htmx->getBlocks()); + } + + /** + * afterRender(): skips non-existent blocks and still clears original content. + * + * @return void + */ + public function testAfterRenderSkipsMissingBlocksAndClearsContent(): void + { + // Mock a view where only 'exists' returns false for all names + $view = $this->getMockBuilder(View::class) + ->setConstructorArgs([$this->Controller->getRequest()]) + ->onlyMethods(['exists']) + ->getMock(); + + $view->method('exists')->willReturn(false); + + // Seed content and request non-existent blocks + $view->assign('content', 'ORIGINAL'); + $this->Htmx->addBlocks(['nope', 'missing', 'ghost']); + + $this->Htmx->afterRender(new Event('View.afterRender', $view)); + + // Content should be cleared, and no appended markup + $this->assertSame('', $view->fetch('content')); + } + + /** + * addTrigger(): later calls with the same key overwrite; mixed null/non-null bodies + * force JSON encoding in prepare(). + * + * @return void + */ + public function testTriggersOverwriteAndMixedBodiesYieldJson(): void + { + // Overwrite same key + $this->Htmx->addTrigger('event', null); + $this->Htmx->addTrigger('event', ['id' => 123]); // last write wins + + // Mix of null and non-null across families => JSON everywhere + $this->Htmx->addTrigger('another', null); + $this->Htmx->addTriggerAfterSettle('settleOne', null); + $this->Htmx->addTriggerAfterSettle('settleTwo', 'ok'); + $this->Htmx->addTriggerAfterSwap('swapOne', null); + $this->Htmx->addTriggerAfterSwap('swapTwo', ['a' => 1]); + + $this->Htmx->prepare(); + $res = $this->Controller->getResponse(); + + $this->assertSame('{"event":{"id":123},"another":null}', $res->getHeaderLine('HX-Trigger')); + $this->assertSame('{"settleOne":null,"settleTwo":"ok"}', $res->getHeaderLine('HX-Trigger-After-Settle')); + $this->assertSame('{"swapOne":null,"swapTwo":{"a":1}}', $res->getHeaderLine('HX-Trigger-After-Swap')); + } + + /** + * setBlock(null) should be safe and not change content or explode. + * + * @return void + */ + public function testSetBlockNullIsSafe(): void + { + $view = $this->getMockBuilder(View::class) + ->setConstructorArgs([$this->Controller->getRequest()]) + ->onlyMethods(['exists']) + ->getMock(); + + $view->method('exists')->willReturn(false); + + $view->assign('content', 'ORIGINAL'); + + $this->Htmx->setBlock(null); + $this->Htmx->afterRender(new Event('View.afterRender', $view)); + + // Nothing was selected -> content stays as-is + $this->assertSame('ORIGINAL', $view->fetch('content')); + } + + /** + * prepare(): must not drop existing response headers that the app has set. + * + * @return void + */ + public function testPreparePreservesExistingHeaders(): void + { + // Seed an application header + $this->Controller = $this->Controller->setResponse( + $this->Controller->getResponse()->withHeader('X-App', 'keep'), + ); + + // Add a trigger so prepare() will write HX-Trigger + $this->Htmx->addTrigger('alpha'); + $this->Htmx->prepare(); + + $res = $this->Controller->getResponse(); + $this->assertSame('keep', $res->getHeaderLine('X-App')); + $this->assertSame('alpha', $res->getHeaderLine('HX-Trigger')); + } + + /** + * Create a stubbed request where is('htmx') always returns true. + * + * Useful for simulating htmx requests in tests without adding + * extra files or named classes. + * + * @return \Cake\Http\ServerRequest + */ + private function makeHtmxTrueRequest(): ServerRequest + { + return new class extends ServerRequest { + public function is(array|string $type, mixed ...$args): bool + { + if ($type === 'htmx' || (is_array($type) && in_array('htmx', $type, true))) { + return true; + } + + return parent::is($type, ...$args); + } + }; + } + + /** + * clearBlocks(): removes all blocks; afterRender() should not change content. + * + * @return void + */ + public function testClearBlocks(): void + { + $view = new View($this->Controller->getRequest()); + $view->assign('content', 'ORIGINAL'); + + $this->Htmx->setBlock('usersTable'); + $this->Htmx->clearBlocks(); + + $this->assertSame([], $this->Htmx->getBlocks()); + + $this->Htmx->afterRender(new Event('View.afterRender', $view)); + $this->assertSame('ORIGINAL', $view->fetch('content')); + } +} diff --git a/tests/TestCase/Middleware/HtmxRequestMiddlewareTest.php b/tests/TestCase/Middleware/HtmxRequestMiddlewareTest.php new file mode 100644 index 0000000..7f91cd4 --- /dev/null +++ b/tests/TestCase/Middleware/HtmxRequestMiddlewareTest.php @@ -0,0 +1,163 @@ +middleware = new HtmxRequestMiddleware(); + } + + /** + * Test that HX-Request header with "true" sets the `htmx` detector. + * + * @return void + */ + public function testProcessSetsHtmxTrueWhenHeaderTrue(): void + { + $request = (new ServerRequest())->withHeader('HX-Request', 'true'); + + $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock(); + $handler->expects($this->once()) + ->method('handle') + ->willReturnCallback(function ($req) { + $this->assertTrue($req->is('htmx')); + $this->assertFalse($req->is('boosted')); + $this->assertFalse($req->is('historyRestoreRequest')); + $this->assertTrue($req->is('htmx-noboost')); + + return new Response(); + }); + + $res = $this->middleware->process($request, $handler); + $this->assertInstanceOf(Response::class, $res); + } + + /** + * Test that HX-Boosted header with "true" sets the `boosted` detector, + * and disables `htmx-noboost`. + * + * @return void + */ + public function testProcessSetsBoostedTrueWhenHeaderTrue(): void + { + $request = (new ServerRequest()) + ->withHeader('HX-Request', 'true') + ->withHeader('HX-Boosted', 'true'); + + $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock(); + $handler->expects($this->once()) + ->method('handle') + ->willReturnCallback(function ($req) { + $this->assertTrue($req->is('htmx')); + $this->assertTrue($req->is('boosted')); + $this->assertFalse($req->is('htmx-noboost')); + + return new Response(); + }); + + $this->middleware->process($request, $handler); + } + + /** + * Test that HX-History-Restore-Request header with "true" sets + * the `historyRestoreRequest` detector. + * + * @return void + */ + public function testProcessSetsHistoryRestoreRequestTrueWhenHeaderTrue(): void + { + $request = (new ServerRequest()) + ->withHeader('HX-Request', 'true') + ->withHeader('HX-History-Restore-Request', 'true'); + + $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock(); + $handler->expects($this->once()) + ->method('handle') + ->willReturnCallback(function ($req) { + $this->assertTrue($req->is('htmx')); + $this->assertTrue($req->is('historyRestoreRequest')); + $this->assertFalse($req->is('boosted')); + $this->assertTrue($req->is('htmx-noboost')); + + return new Response(); + }); + + $this->middleware->process($request, $handler); + } + + /** + * Test that "false" values in HX headers evaluate correctly as false. + * + * @return void + */ + public function testProcessFalseValuesAreHandled(): void + { + $request = (new ServerRequest()) + ->withHeader('HX-Request', 'false') + ->withHeader('HX-Boosted', 'false') + ->withHeader('HX-History-Restore-Request', 'false'); + + $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock(); + $handler->expects($this->once()) + ->method('handle') + ->willReturnCallback(function ($req) { + $this->assertFalse($req->is('htmx')); + $this->assertFalse($req->is('boosted')); + $this->assertFalse($req->is('historyRestoreRequest')); + $this->assertFalse($req->is('htmx-noboost')); + + return new Response(); + }); + + $this->middleware->process($request, $handler); + } + + /** + * Test that when no HX headers are present, all detectors evaluate to false. + * + * @return void + */ + public function testProcessMissingHeadersDefaultToFalse(): void + { + $request = new ServerRequest(); + + $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock(); + $handler->expects($this->once()) + ->method('handle') + ->willReturnCallback(function ($req) { + $this->assertFalse($req->is('htmx')); + $this->assertFalse($req->is('boosted')); + $this->assertFalse($req->is('historyRestoreRequest')); + $this->assertFalse($req->is('htmx-noboost')); + + return new Response(); + }); + + $this->middleware->process($request, $handler); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ddf23a7..49c1a56 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -38,18 +38,3 @@ return; } - -/** - * Load schema from a SQL dump file. - * - * If your plugin does not use database fixtures you can - * safely delete this. - * - * If you want to support multiple databases, consider - * using migrations to provide schema for your plugin, - * and using \Migrations\TestSuite\Migrator to load schema. - */ -use Cake\TestSuite\Fixture\SchemaLoader; - -// Load a schema dump file. -(new SchemaLoader())->loadSqlFiles('tests/schema.sql', 'test'); diff --git a/tests/schema.sql b/tests/schema.sql deleted file mode 100644 index cb878dd..0000000 --- a/tests/schema.sql +++ /dev/null @@ -1 +0,0 @@ --- Test database schema for CakeHtmx