From e0766c119829988691be01665488d73b1776f287 Mon Sep 17 00:00:00 2001 From: Younes ENNAJI Date: Sat, 7 Mar 2026 18:28:24 +0000 Subject: [PATCH] Add v2.5.0 release preparation tests and update CHANGELOG --- CHANGELOG.md | 38 ++- src/Laravel/composer.json | 2 +- src/Noty/Laravel/composer.json | 4 +- src/Noty/Prime/Resources/package.json | 4 +- src/Noty/Prime/composer.json | 2 +- src/Noty/Symfony/composer.json | 4 +- src/Notyf/Laravel/composer.json | 4 +- src/Notyf/Prime/Resources/package.json | 4 +- src/Notyf/Prime/composer.json | 2 +- src/Notyf/Symfony/composer.json | 4 +- src/Prime/Flasher.php | 2 +- src/Prime/Resources/package.json | 2 +- .../Filter/Criteria/FilterCriteria.php | 5 +- src/SweetAlert/Laravel/composer.json | 4 +- src/SweetAlert/Prime/Resources/package.json | 4 +- src/SweetAlert/Prime/composer.json | 2 +- src/SweetAlert/Symfony/composer.json | 4 +- src/Symfony/composer.json | 2 +- src/Toastr/Laravel/composer.json | 4 +- src/Toastr/Prime/Resources/package.json | 4 +- src/Toastr/Prime/composer.json | 2 +- src/Toastr/Symfony/composer.json | 4 +- tests/Prime/ConfigurationTest.php | 239 +++++++++++++++ tests/Prime/FunctionsTest.php | 238 +++++++++++++++ .../Csp/ContentSecurityPolicyHandlerTest.php | 244 +++++++++++++++ tests/Prime/Http/RequestExtensionTest.php | 114 +++++++ tests/Prime/Http/ResponseExtensionTest.php | 280 ++++++++++++++++++ 27 files changed, 1175 insertions(+), 47 deletions(-) create mode 100644 tests/Prime/ConfigurationTest.php create mode 100644 tests/Prime/FunctionsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6490febf..2a849c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,34 @@ # CHANGELOG for 2.x -## [Unreleased](https://github.com/php-flasher/php-flasher/compare/v2.1.4...2.x) +## [Unreleased](https://github.com/php-flasher/php-flasher/compare/v2.2.0...2.x) + +## [v2.5.0](https://github.com/php-flasher/php-flasher/compare/v2.1.4...v2.2.0) - 2026-03-07 + +### Added * feature [Laravel] Improve Laravel Octane support by resetting FallbackSession static storage between requests to prevent notification leakage * feature [Symfony] Add FrankenPHP/Swoole/RoadRunner support with WorkerListener that implements ResetInterface and is tagged with kernel.reset * feature [Symfony] Add reset() method to FallbackSession for long-running process support * feature [Flasher] Add Hotwire/Turbo Drive support with turbo:before-cache event listener to clean up notifications before page caching -* fix [Flasher] Fix potential runtime error in Envelope::toArray() when no PresentableStampInterface stamps exist -* fix [Flasher] Use more specific \Random\RandomException in IdStamp instead of broad \Exception -* fix [Flasher] Update Livewire navigation cleanup to use correct .fl-wrapper selector instead of unused .fl-no-cache class * feature [Flasher] Add event dispatching system for all notification adapters and themes with Livewire integration: - [Toastr] Dispatch events: `flasher:toastr:click`, `flasher:toastr:close`, `flasher:toastr:show`, `flasher:toastr:hidden` - [Noty] Dispatch events: `flasher:noty:click`, `flasher:noty:close`, `flasher:noty:show`, `flasher:noty:hover` - [Notyf] Dispatch events: `flasher:notyf:click`, `flasher:notyf:dismiss` - [Themes] Dispatch events: `flasher:theme:click` (generic) and `flasher:theme:{name}:click` (specific) - [Laravel] Add LivewireListener classes for all adapters and themes to enable Livewire event handling +* feature [Flasher] Add 16 notification themes: Amazon, Amber, Jade, Crystal, and more +* feature [DX] Add `@method` annotations to FlasherInterface and NotificationFactoryInterface for better IDE autocompletion +* feature [DX] Add Type::all() and Type::isValid() helper methods with PHPStan type narrowing +* feature [DX] Add `@throws` annotations to FlasherContainer methods for better exception documentation +* feature [DX] Add FlasherContainer::setContainer() method as convenient alias for testing +* feature [DX] Add PHPStan type alias `NotificationType` for valid notification types + +### Fixed + +* fix [SweetAlert] Fix SweetAlertBuilder::question() bug where options parameter was being ignored +* fix [Flasher] Fix potential runtime error in Envelope::toArray() when no PresentableStampInterface stamps exist +* fix [Flasher] Use more specific \Random\RandomException in IdStamp instead of broad \Exception +* fix [Flasher] Update Livewire navigation cleanup to use correct .fl-wrapper selector instead of unused .fl-no-cache class * fix [Flasher] Fix FilterCriteria uninitialized property error when constructed with empty array * fix [Flasher] Fix null comparison issues in PriorityCriteria, HopsCriteria, and DelayCriteria that relied on PHP's implicit null-to-0 coercion * fix [Flasher] Add type validation for callable factory return values in NotificationFactoryLocator with descriptive error messages @@ -28,13 +42,15 @@ * fix [Flasher] Add type validation for callable presenter return values in ResponseManager with descriptive error messages * fix [Flasher] Fix FlasherPlugin::normalizePlugins() losing scripts/styles when both top-level and plugin-level configs are provided - replaced array union operator with array_merge * fix [Flasher] Simplify FlasherPlugin::normalizeFlashBag() by replacing redundant array union with direct array_merge -* fix [Flasher] Standardize exception message format in PresetNotFoundException to use brackets like other exceptions -* fix [Flasher] Standardize exception message wording in CriteriaNotRegisteredException to use "not found" instead of "is not found" -* feature [DX] Add `@method` annotations to FlasherInterface and NotificationFactoryInterface for better IDE autocompletion -* feature [DX] Add Type::all() and Type::isValid() helper methods with PHPStan type narrowing -* feature [DX] Add `@throws` annotations to FlasherContainer methods for better exception documentation -* feature [DX] Add FlasherContainer::setContainer() method as convenient alias for testing -* feature [DX] Add PHPStan type alias `NotificationType` for valid notification types +* fix [Flasher] Fix array_merge() syntax in InstallCommand by removing unnecessary empty array +* fix [Flasher] Add return type validation in FilterCriteria::apply() with proper exception handling + +### Changed + +* refactor [Flasher] Reduce theme configuration duplication by generating themes programmatically +* refactor [Flasher] Standardize exception message format in PresetNotFoundException to use brackets like other exceptions +* refactor [Flasher] Standardize exception message wording in CriteriaNotRegisteredException to use "not found" instead of "is not found" +* refactor [SweetAlert] Replace @phpstan-ignore-line suppressions with proper @var annotations ## [v2.1.3](https://github.com/php-flasher/php-flasher/compare/v2.1.2...v2.1.3) - 2025-01-25 diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index d606f057..88ed5c13 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -29,7 +29,7 @@ "require": { "php": ">=8.2", "illuminate/support": "^11.0|^12.0|^13.0", - "php-flasher/flasher": "^2.4.0" + "php-flasher/flasher": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/src/Noty/Laravel/composer.json b/src/Noty/Laravel/composer.json index ef9cae7f..35e39ba6 100644 --- a/src/Noty/Laravel/composer.json +++ b/src/Noty/Laravel/composer.json @@ -28,8 +28,8 @@ "prefer-stable": true, "require": { "php": ">=8.2", - "php-flasher/flasher-laravel": "^2.4.0", - "php-flasher/flasher-noty": "^2.4.0" + "php-flasher/flasher-laravel": "^2.5.0", + "php-flasher/flasher-noty": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/src/Noty/Prime/Resources/package.json b/src/Noty/Prime/Resources/package.json index 0e1058e2..c1ff4421 100644 --- a/src/Noty/Prime/Resources/package.json +++ b/src/Noty/Prime/Resources/package.json @@ -1,6 +1,6 @@ { "name": "@flasher/flasher-noty", - "version": "2.4.0", + "version": "2.5.0", "type": "module", "license": "MIT", "main": "dist/flasher-noty.cjs.js", @@ -11,7 +11,7 @@ "ncu": "ncu -u" }, "peerDependencies": { - "@flasher/flasher": "^2.4.0", + "@flasher/flasher": "^2.5.0", "noty": "^3.2.0-beta-deprecated" } } diff --git a/src/Noty/Prime/composer.json b/src/Noty/Prime/composer.json index 79edb3a2..577b0a96 100644 --- a/src/Noty/Prime/composer.json +++ b/src/Noty/Prime/composer.json @@ -33,7 +33,7 @@ "prefer-stable": true, "require": { "php": ">=8.2", - "php-flasher/flasher": "^2.4.0" + "php-flasher/flasher": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/src/Noty/Symfony/composer.json b/src/Noty/Symfony/composer.json index b4b721e8..3f9b7e62 100644 --- a/src/Noty/Symfony/composer.json +++ b/src/Noty/Symfony/composer.json @@ -28,8 +28,8 @@ "prefer-stable": true, "require": { "php": ">=8.2", - "php-flasher/flasher-noty": "^2.4.0", - "php-flasher/flasher-symfony": "^2.4.0" + "php-flasher/flasher-noty": "^2.5.0", + "php-flasher/flasher-symfony": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/src/Notyf/Laravel/composer.json b/src/Notyf/Laravel/composer.json index cb2ccbc5..6a760b13 100644 --- a/src/Notyf/Laravel/composer.json +++ b/src/Notyf/Laravel/composer.json @@ -29,8 +29,8 @@ "prefer-stable": true, "require": { "php": ">=8.2", - "php-flasher/flasher-laravel": "^2.4.0", - "php-flasher/flasher-notyf": "^2.4.0" + "php-flasher/flasher-laravel": "^2.5.0", + "php-flasher/flasher-notyf": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/src/Notyf/Prime/Resources/package.json b/src/Notyf/Prime/Resources/package.json index 70f676ed..fe3132b8 100644 --- a/src/Notyf/Prime/Resources/package.json +++ b/src/Notyf/Prime/Resources/package.json @@ -1,6 +1,6 @@ { "name": "@flasher/flasher-notyf", - "version": "2.4.0", + "version": "2.5.0", "type": "module", "license": "MIT", "main": "dist/flasher-notyf.cjs.js", @@ -11,7 +11,7 @@ "ncu": "ncu -u" }, "peerDependencies": { - "@flasher/flasher": "^2.4.0", + "@flasher/flasher": "^2.5.0", "notyf": "^3.10.0" } } diff --git a/src/Notyf/Prime/composer.json b/src/Notyf/Prime/composer.json index 908bc926..d638b97c 100644 --- a/src/Notyf/Prime/composer.json +++ b/src/Notyf/Prime/composer.json @@ -33,7 +33,7 @@ "prefer-stable": true, "require": { "php": ">=8.2", - "php-flasher/flasher": "^2.4.0" + "php-flasher/flasher": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/src/Notyf/Symfony/composer.json b/src/Notyf/Symfony/composer.json index b051f1a5..0d1abe16 100644 --- a/src/Notyf/Symfony/composer.json +++ b/src/Notyf/Symfony/composer.json @@ -29,8 +29,8 @@ "prefer-stable": true, "require": { "php": ">=8.2", - "php-flasher/flasher-notyf": "^2.4.0", - "php-flasher/flasher-symfony": "^2.4.0" + "php-flasher/flasher-notyf": "^2.5.0", + "php-flasher/flasher-symfony": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/src/Prime/Flasher.php b/src/Prime/Flasher.php index de63d19f..20979507 100644 --- a/src/Prime/Flasher.php +++ b/src/Prime/Flasher.php @@ -18,7 +18,7 @@ final readonly class Flasher implements FlasherInterface { use ForwardsCalls; - public const VERSION = '2.4.0'; + public const VERSION = '2.5.0'; public function __construct( private string $default, diff --git a/src/Prime/Resources/package.json b/src/Prime/Resources/package.json index 2d2364b1..674d9002 100644 --- a/src/Prime/Resources/package.json +++ b/src/Prime/Resources/package.json @@ -1,6 +1,6 @@ { "name": "@flasher/flasher", - "version": "2.4.0", + "version": "2.5.0", "type": "module", "license": "MIT", "main": "dist/flasher.cjs.js", diff --git a/src/Prime/Storage/Filter/Criteria/FilterCriteria.php b/src/Prime/Storage/Filter/Criteria/FilterCriteria.php index 0558adfb..975c5d13 100644 --- a/src/Prime/Storage/Filter/Criteria/FilterCriteria.php +++ b/src/Prime/Storage/Filter/Criteria/FilterCriteria.php @@ -45,10 +45,7 @@ final class FilterCriteria implements CriteriaInterface $result = $callback($envelopes); if (!\is_array($result)) { - throw new \InvalidArgumentException(\sprintf( - 'Filter callback must return an array, got "%s".', - get_debug_type($result) - )); + throw new \InvalidArgumentException(\sprintf('Filter callback must return an array, got "%s".', get_debug_type($result))); } /** @var Envelope[] $result */ diff --git a/src/SweetAlert/Laravel/composer.json b/src/SweetAlert/Laravel/composer.json index a11e180a..4c3f6519 100644 --- a/src/SweetAlert/Laravel/composer.json +++ b/src/SweetAlert/Laravel/composer.json @@ -30,8 +30,8 @@ "prefer-stable": true, "require": { "php": ">=8.2", - "php-flasher/flasher-laravel": "^2.4.0", - "php-flasher/flasher-sweetalert": "^2.4.0" + "php-flasher/flasher-laravel": "^2.5.0", + "php-flasher/flasher-sweetalert": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/src/SweetAlert/Prime/Resources/package.json b/src/SweetAlert/Prime/Resources/package.json index ccd0699d..4e23ab8c 100644 --- a/src/SweetAlert/Prime/Resources/package.json +++ b/src/SweetAlert/Prime/Resources/package.json @@ -1,6 +1,6 @@ { "name": "@flasher/flasher-sweetalert", - "version": "2.4.0", + "version": "2.5.0", "type": "module", "license": "MIT", "main": "dist/flasher-sweetalert.cjs.js", @@ -11,7 +11,7 @@ "ncu": "ncu -u" }, "peerDependencies": { - "@flasher/flasher": "^2.4.0", + "@flasher/flasher": "^2.5.0", "sweetalert2": "^11.6.13" } } diff --git a/src/SweetAlert/Prime/composer.json b/src/SweetAlert/Prime/composer.json index 82ad31a9..8e1800ff 100644 --- a/src/SweetAlert/Prime/composer.json +++ b/src/SweetAlert/Prime/composer.json @@ -33,7 +33,7 @@ "prefer-stable": true, "require": { "php": ">=8.2", - "php-flasher/flasher": "^2.4.0" + "php-flasher/flasher": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/src/SweetAlert/Symfony/composer.json b/src/SweetAlert/Symfony/composer.json index 6d133616..30d0a93b 100644 --- a/src/SweetAlert/Symfony/composer.json +++ b/src/SweetAlert/Symfony/composer.json @@ -30,8 +30,8 @@ "prefer-stable": true, "require": { "php": ">=8.2", - "php-flasher/flasher-sweetalert": "^2.4.0", - "php-flasher/flasher-symfony": "^2.4.0" + "php-flasher/flasher-sweetalert": "^2.5.0", + "php-flasher/flasher-symfony": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index 51345876..1ec16d82 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -28,7 +28,7 @@ "prefer-stable": true, "require": { "php": ">=8.2", - "php-flasher/flasher": "^2.4.0", + "php-flasher/flasher": "^2.5.0", "symfony/config": "^7.0|^8.0", "symfony/console": "^7.0|^8.0", "symfony/dependency-injection": "^7.0|^8.0", diff --git a/src/Toastr/Laravel/composer.json b/src/Toastr/Laravel/composer.json index 147bc2a8..0ebccba1 100644 --- a/src/Toastr/Laravel/composer.json +++ b/src/Toastr/Laravel/composer.json @@ -29,8 +29,8 @@ "prefer-stable": true, "require": { "php": ">=8.2", - "php-flasher/flasher-laravel": "^2.4.0", - "php-flasher/flasher-toastr": "^2.4.0" + "php-flasher/flasher-laravel": "^2.5.0", + "php-flasher/flasher-toastr": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/src/Toastr/Prime/Resources/package.json b/src/Toastr/Prime/Resources/package.json index 2c51c2de..3b1ce903 100644 --- a/src/Toastr/Prime/Resources/package.json +++ b/src/Toastr/Prime/Resources/package.json @@ -1,6 +1,6 @@ { "name": "@flasher/flasher-toastr", - "version": "2.4.0", + "version": "2.5.0", "type": "module", "license": "MIT", "main": "dist/flasher-toastr.cjs.js", @@ -11,7 +11,7 @@ "ncu": "ncu -u" }, "peerDependencies": { - "@flasher/flasher": "^2.4.0", + "@flasher/flasher": "^2.5.0", "toastr": "^2.1.4" }, "devDependencies": { diff --git a/src/Toastr/Prime/composer.json b/src/Toastr/Prime/composer.json index f7f90922..18c4951d 100644 --- a/src/Toastr/Prime/composer.json +++ b/src/Toastr/Prime/composer.json @@ -33,7 +33,7 @@ "prefer-stable": true, "require": { "php": ">=8.2", - "php-flasher/flasher": "^2.4.0" + "php-flasher/flasher": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/src/Toastr/Symfony/composer.json b/src/Toastr/Symfony/composer.json index b888af42..5f698e32 100644 --- a/src/Toastr/Symfony/composer.json +++ b/src/Toastr/Symfony/composer.json @@ -29,8 +29,8 @@ "prefer-stable": true, "require": { "php": ">=8.2", - "php-flasher/flasher-symfony": "^2.4.0", - "php-flasher/flasher-toastr": "^2.4.0" + "php-flasher/flasher-symfony": "^2.5.0", + "php-flasher/flasher-toastr": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/tests/Prime/ConfigurationTest.php b/tests/Prime/ConfigurationTest.php new file mode 100644 index 00000000..db8dd4db --- /dev/null +++ b/tests/Prime/ConfigurationTest.php @@ -0,0 +1,239 @@ + 'flasher']; + + $result = Configuration::from($config); + + $this->assertSame($config, $result); + } + + public function testFromWithMinimalConfig(): void + { + $config = [ + 'default' => 'toastr', + ]; + + $result = Configuration::from($config); + + $this->assertSame($config, $result); + $this->assertSame('toastr', $result['default']); + } + + public function testFromWithFullConfig(): void + { + $config = [ + 'default' => 'flasher', + 'main_script' => '/assets/flasher.min.js', + 'scripts' => ['/assets/plugin1.js', '/assets/plugin2.js'], + 'styles' => ['/assets/flasher.min.css'], + 'inject_assets' => true, + 'translate' => true, + 'excluded_paths' => ['/admin', '/api'], + 'options' => ['timeout' => 5000, 'position' => 'top-right'], + 'filter' => ['limit' => 5], + ]; + + $result = Configuration::from($config); + + $this->assertSame($config, $result); + $this->assertSame('flasher', $result['default']); + $this->assertSame('/assets/flasher.min.js', $result['main_script']); + $this->assertCount(2, $result['scripts']); + $this->assertCount(1, $result['styles']); + $this->assertTrue($result['inject_assets']); + $this->assertTrue($result['translate']); + $this->assertCount(2, $result['excluded_paths']); + $this->assertSame(5000, $result['options']['timeout']); + $this->assertSame(5, $result['filter']['limit']); + } + + public function testFromWithPluginsConfig(): void + { + $config = [ + 'default' => 'flasher', + 'plugins' => [ + 'toastr' => [ + 'scripts' => ['/assets/toastr.min.js'], + 'styles' => ['/assets/toastr.min.css'], + 'options' => ['closeButton' => true], + ], + 'sweetalert' => [ + 'scripts' => ['/assets/sweetalert.min.js'], + 'styles' => ['/assets/sweetalert.min.css'], + 'options' => ['showConfirmButton' => false], + ], + ], + ]; + + $result = Configuration::from($config); + + $this->assertSame($config, $result); + $this->assertArrayHasKey('plugins', $result); + $this->assertArrayHasKey('toastr', $result['plugins']); + $this->assertArrayHasKey('sweetalert', $result['plugins']); + $this->assertSame(['/assets/toastr.min.js'], $result['plugins']['toastr']['scripts']); + $this->assertTrue($result['plugins']['toastr']['options']['closeButton']); + } + + public function testFromWithPresetsConfig(): void + { + $config = [ + 'default' => 'flasher', + 'presets' => [ + 'entity_saved' => [ + 'type' => 'success', + 'title' => 'Saved', + 'message' => 'Entity has been saved successfully.', + 'options' => ['timeout' => 3000], + ], + 'entity_deleted' => [ + 'type' => 'error', + 'title' => 'Deleted', + 'message' => 'Entity has been deleted.', + 'options' => ['timeout' => 5000], + ], + ], + ]; + + $result = Configuration::from($config); + + $this->assertSame($config, $result); + $this->assertArrayHasKey('presets', $result); + $this->assertArrayHasKey('entity_saved', $result['presets']); + $this->assertSame('success', $result['presets']['entity_saved']['type']); + $this->assertSame('Saved', $result['presets']['entity_saved']['title']); + $this->assertSame('Entity has been saved successfully.', $result['presets']['entity_saved']['message']); + $this->assertSame(3000, $result['presets']['entity_saved']['options']['timeout']); + } + + public function testFromWithFlashBagConfig(): void + { + $config = [ + 'default' => 'flasher', + 'flash_bag' => [ + 'success' => ['success', 'ok'], + 'error' => ['error', 'danger', 'fail'], + 'warning' => ['warning', 'warn'], + 'info' => ['info', 'notice'], + ], + ]; + + $result = Configuration::from($config); + + $this->assertSame($config, $result); + $this->assertArrayHasKey('flash_bag', $result); + $this->assertIsArray($result['flash_bag']); + $this->assertSame(['success', 'ok'], $result['flash_bag']['success']); + $this->assertSame(['error', 'danger', 'fail'], $result['flash_bag']['error']); + } + + public function testFromWithFlashBagDisabled(): void + { + $config = [ + 'default' => 'flasher', + 'flash_bag' => false, + ]; + + $result = Configuration::from($config); + + $this->assertSame($config, $result); + $this->assertFalse($result['flash_bag']); + } + + public function testFromWithThemesConfig(): void + { + $config = [ + 'default' => 'flasher', + 'plugins' => [ + 'theme.amazon' => [ + 'scripts' => ['/assets/themes/amazon.js'], + 'styles' => ['/assets/themes/amazon.css'], + 'options' => ['position' => 'bottom-right'], + ], + 'theme.bootstrap' => [ + 'scripts' => ['/assets/themes/bootstrap.js'], + 'styles' => ['/assets/themes/bootstrap.css'], + 'options' => ['position' => 'top-center'], + ], + ], + ]; + + $result = Configuration::from($config); + + $this->assertSame($config, $result); + $this->assertArrayHasKey('plugins', $result); + $this->assertArrayHasKey('theme.amazon', $result['plugins']); + $this->assertArrayHasKey('theme.bootstrap', $result['plugins']); + } + + public function testFromPreservesConfigValues(): void + { + $originalConfig = [ + 'default' => 'noty', + 'main_script' => '/custom/path/flasher.js', + 'scripts' => ['/custom/script1.js'], + 'styles' => ['/custom/style1.css'], + 'inject_assets' => false, + 'translate' => false, + 'excluded_paths' => ['/api/v1', '/api/v2'], + 'options' => [ + 'timeout' => 10000, + 'position' => 'bottom-left', + 'closeButton' => true, + ], + 'filter' => [ + 'limit' => 10, + 'orderBy' => 'priority', + ], + 'presets' => [ + 'my_preset' => [ + 'type' => 'info', + 'title' => 'Info', + 'message' => 'This is info', + 'options' => [], + ], + ], + 'plugins' => [ + 'noty' => [ + 'scripts' => ['/noty.js'], + 'styles' => ['/noty.css'], + 'options' => ['layout' => 'topRight'], + ], + ], + ]; + + $result = Configuration::from($originalConfig); + + // Verify exact pass-through behavior + $this->assertSame($originalConfig, $result); + + // Verify no mutation occurred + $this->assertSame('noty', $result['default']); + $this->assertFalse($result['inject_assets']); + $this->assertFalse($result['translate']); + $this->assertSame(10000, $result['options']['timeout']); + $this->assertSame('topRight', $result['plugins']['noty']['options']['layout']); + } + + public function testFromReturnsArrayByReference(): void + { + $config = ['default' => 'flasher']; + + $result1 = Configuration::from($config); + $result2 = Configuration::from($config); + + // Both should be equal + $this->assertSame($result1, $result2); + } +} diff --git a/tests/Prime/FunctionsTest.php b/tests/Prime/FunctionsTest.php new file mode 100644 index 00000000..74f45bd1 --- /dev/null +++ b/tests/Prime/FunctionsTest.php @@ -0,0 +1,238 @@ +allows('add')->andReturnUsing(fn ($envelope) => $envelope); + + $this->flasher = new Flasher('flasher', $factoryLocator, $responseManager, $storageManager); + + FlasherContainer::reset(); + FlasherContainer::setContainer($this->flasher); + } + + protected function tearDown(): void + { + FlasherContainer::reset(); + + parent::tearDown(); + } + + public function testFlashWithNoArgumentsReturnsFlasherInterface(): void + { + $result = namespacedFlash(); + + $this->assertInstanceOf(FlasherInterface::class, $result); + } + + public function testFlashWithMessageReturnsEnvelope(): void + { + $result = namespacedFlash('Hello World'); + + $this->assertInstanceOf(Envelope::class, $result); + $this->assertSame('Hello World', $result->getMessage()); + $this->assertSame(Type::SUCCESS, $result->getType()); + } + + public function testFlashWithAllArgumentsReturnsEnvelope(): void + { + $result = namespacedFlash( + 'Operation completed', + Type::INFO, + ['timeout' => 5000], + 'Custom Title' + ); + + $this->assertInstanceOf(Envelope::class, $result); + $this->assertSame('Operation completed', $result->getMessage()); + $this->assertSame(Type::INFO, $result->getType()); + $this->assertSame('Custom Title', $result->getTitle()); + $this->assertSame(5000, $result->getOption('timeout')); + } + + public function testFlashWithDifferentTypes(): void + { + $successResult = namespacedFlash('Success message', Type::SUCCESS); + $this->assertSame(Type::SUCCESS, $successResult->getType()); + + $errorResult = namespacedFlash('Error message', Type::ERROR); + $this->assertSame(Type::ERROR, $errorResult->getType()); + + $warningResult = namespacedFlash('Warning message', Type::WARNING); + $this->assertSame(Type::WARNING, $warningResult->getType()); + + $infoResult = namespacedFlash('Info message', Type::INFO); + $this->assertSame(Type::INFO, $infoResult->getType()); + } + + public function testFlashWithOptions(): void + { + $options = [ + 'timeout' => 3000, + 'position' => 'top-right', + 'closeButton' => true, + ]; + + $result = namespacedFlash('Test message', Type::SUCCESS, $options); + + $this->assertInstanceOf(Envelope::class, $result); + $this->assertSame(3000, $result->getOption('timeout')); + $this->assertSame('top-right', $result->getOption('position')); + $this->assertTrue($result->getOption('closeButton')); + } + + public function testFlashWithTitle(): void + { + $result = namespacedFlash('Message body', Type::SUCCESS, [], 'Notification Title'); + + $this->assertInstanceOf(Envelope::class, $result); + $this->assertSame('Notification Title', $result->getTitle()); + $this->assertSame('Message body', $result->getMessage()); + } + + public function testNamespacedFlashFunction(): void + { + // Test that the namespaced function exists and works + $this->assertTrue(\function_exists('Flasher\Prime\flash')); + + $result = namespacedFlash(); + $this->assertInstanceOf(FlasherInterface::class, $result); + + $envelope = namespacedFlash('Test message'); + $this->assertInstanceOf(Envelope::class, $envelope); + } + + public function testGlobalFlashFunction(): void + { + // Ensure the global function exists + $this->assertTrue(\function_exists('flash')); + + $result = flash(); + $this->assertInstanceOf(FlasherInterface::class, $result); + + $envelope = flash('Test message'); + $this->assertInstanceOf(Envelope::class, $envelope); + } + + public function testBothFunctionsReturnSameResult(): void + { + // Both functions should use the same container and return equivalent results + $namespacedResult = namespacedFlash(); + $globalResult = flash(); + + // Both should return the same FlasherInterface instance + $this->assertInstanceOf(FlasherInterface::class, $namespacedResult); + $this->assertInstanceOf(FlasherInterface::class, $globalResult); + + // Create notifications with both functions + $namespacedEnvelope = namespacedFlash('Test message', Type::SUCCESS); + $globalEnvelope = flash('Test message', Type::SUCCESS); + + // Both should produce Envelopes with the same structure + $this->assertInstanceOf(Envelope::class, $namespacedEnvelope); + $this->assertInstanceOf(Envelope::class, $globalEnvelope); + $this->assertSame('Test message', $namespacedEnvelope->getMessage()); + $this->assertSame('Test message', $globalEnvelope->getMessage()); + $this->assertSame(Type::SUCCESS, $namespacedEnvelope->getType()); + $this->assertSame(Type::SUCCESS, $globalEnvelope->getType()); + } + + public function testFlashWithEmptyMessage(): void + { + $result = namespacedFlash(''); + + $this->assertInstanceOf(Envelope::class, $result); + $this->assertSame('', $result->getMessage()); + } + + public function testFlashWithNullMessage(): void + { + // When message is null and func_num_args is 0, returns FlasherInterface + // When message is explicitly null but args > 0, should still flash + $result = namespacedFlash(null); + + // Based on the function signature, passing null should trigger the envelope creation + $this->assertInstanceOf(Envelope::class, $result); + } + + public function testFlashWithUnicodeMessage(): void + { + $unicodeMessage = 'Hello'; + + $result = namespacedFlash($unicodeMessage, Type::INFO, [], 'Title'); + + $this->assertInstanceOf(Envelope::class, $result); + $this->assertSame($unicodeMessage, $result->getMessage()); + $this->assertSame('Title', $result->getTitle()); + } + + public function testFlashWithSpecialCharactersInMessage(): void + { + $specialMessage = ' & "quotes" \'single\''; + + $result = namespacedFlash($specialMessage); + + $this->assertInstanceOf(Envelope::class, $result); + $this->assertSame($specialMessage, $result->getMessage()); + } + + public function testFlashWithEmptyOptions(): void + { + $result = namespacedFlash('Message', Type::SUCCESS, []); + + $this->assertInstanceOf(Envelope::class, $result); + // Options should be empty or have default values + $options = $result->getOptions(); + $this->assertIsArray($options); + } + + public function testFlashWithNullTitle(): void + { + $result = namespacedFlash('Message', Type::ERROR, [], null); + + $this->assertInstanceOf(Envelope::class, $result); + // Title should be empty string when null is passed + $this->assertSame('', $result->getTitle()); + } + + public function testFlashChainability(): void + { + // When called without arguments, should return FlasherInterface + // which can be used for chaining + $flasher = namespacedFlash(); + + $this->assertInstanceOf(FlasherInterface::class, $flasher); + + // Should be able to use the flasher methods + $envelope = $flasher->success('Success message'); + $this->assertInstanceOf(Envelope::class, $envelope); + } +} diff --git a/tests/Prime/Http/Csp/ContentSecurityPolicyHandlerTest.php b/tests/Prime/Http/Csp/ContentSecurityPolicyHandlerTest.php index 8cacf9a3..d0212838 100644 --- a/tests/Prime/Http/Csp/ContentSecurityPolicyHandlerTest.php +++ b/tests/Prime/Http/Csp/ContentSecurityPolicyHandlerTest.php @@ -519,4 +519,248 @@ final class ContentSecurityPolicyHandlerTest extends TestCase $resultCsp = $headers['Content-Security-Policy']; $this->assertStringNotContainsString('; ;', $resultCsp); } + + public function testHandlesCsp3Directives(): void + { + $this->nonceGeneratorMock->method('generate')->willReturn('csp3nonce'); + + $this->requestMock->method('hasHeader')->willReturn(false); + + $headers = []; + $this->responseMock->method('hasHeader')->willReturnCallback(function ($name) use (&$headers) { + return isset($headers[$name]); + }); + $this->responseMock->method('getHeader')->willReturnCallback(function ($name) use (&$headers) { + return $headers[$name] ?? null; + }); + $this->responseMock->method('setHeader')->willReturnCallback(function ($name, $value) use (&$headers) { + $headers[$name] = $value; + }); + $this->responseMock->method('removeHeader')->willReturnCallback(function ($name) use (&$headers) { + unset($headers[$name]); + }); + + // CSP Level 3 directives + $headers['Content-Security-Policy'] = "script-src 'self'; worker-src 'self'; navigate-to 'self'"; + + $nonces = $this->cspHandler->updateResponseHeaders($this->requestMock, $this->responseMock); + + $this->assertNotEmpty($nonces); + $this->assertArrayHasKey('csp_script_nonce', $nonces); + + // The handler should add nonces to script-src but leave worker-src and navigate-to alone + $resultCsp = $headers['Content-Security-Policy']; + $this->assertStringContainsString("'nonce-csp3nonce'", $resultCsp); + } + + public function testHandlesMultipleSourcesInDirective(): void + { + $this->nonceGeneratorMock->method('generate')->willReturn('multisourcenonce'); + + $this->requestMock->method('hasHeader')->willReturn(false); + + $headers = []; + $this->responseMock->method('hasHeader')->willReturnCallback(function ($name) use (&$headers) { + return isset($headers[$name]); + }); + $this->responseMock->method('getHeader')->willReturnCallback(function ($name) use (&$headers) { + return $headers[$name] ?? null; + }); + $this->responseMock->method('setHeader')->willReturnCallback(function ($name, $value) use (&$headers) { + $headers[$name] = $value; + }); + $this->responseMock->method('removeHeader')->willReturnCallback(function ($name) use (&$headers) { + unset($headers[$name]); + }); + + // CSP with multiple sources in a single directive + $headers['Content-Security-Policy'] = "script-src 'self' https://cdn.example.com https://api.example.com data: blob:"; + + $nonces = $this->cspHandler->updateResponseHeaders($this->requestMock, $this->responseMock); + + $this->assertNotEmpty($nonces); + + $resultCsp = $headers['Content-Security-Policy']; + // Should preserve existing sources and add nonce + $this->assertStringContainsString("'self'", $resultCsp); + $this->assertStringContainsString('https://cdn.example.com', $resultCsp); + $this->assertStringContainsString('https://api.example.com', $resultCsp); + $this->assertStringContainsString('data:', $resultCsp); + $this->assertStringContainsString('blob:', $resultCsp); + $this->assertStringContainsString("'nonce-multisourcenonce'", $resultCsp); + } + + public function testHandlesVeryLongCspHeader(): void + { + $this->nonceGeneratorMock->method('generate')->willReturn('longnonce'); + + $this->requestMock->method('hasHeader')->willReturn(false); + + $headers = []; + $this->responseMock->method('hasHeader')->willReturnCallback(function ($name) use (&$headers) { + return isset($headers[$name]); + }); + $this->responseMock->method('getHeader')->willReturnCallback(function ($name) use (&$headers) { + return $headers[$name] ?? null; + }); + $this->responseMock->method('setHeader')->willReturnCallback(function ($name, $value) use (&$headers) { + $headers[$name] = $value; + }); + $this->responseMock->method('removeHeader')->willReturnCallback(function ($name) use (&$headers) { + unset($headers[$name]); + }); + + // Generate a very long CSP header with many directives + $domains = []; + for ($i = 0; $i < 50; ++$i) { + $domains[] = "https://cdn{$i}.example.com"; + } + $longCsp = "script-src 'self' ".implode(' ', $domains)."; style-src 'self' ".implode(' ', $domains); + + $headers['Content-Security-Policy'] = $longCsp; + + $nonces = $this->cspHandler->updateResponseHeaders($this->requestMock, $this->responseMock); + + $this->assertNotEmpty($nonces); + + $resultCsp = $headers['Content-Security-Policy']; + // Should handle the long header without issues + $this->assertStringContainsString("'nonce-longnonce'", $resultCsp); + // Verify multiple domains are preserved + $this->assertStringContainsString('https://cdn0.example.com', $resultCsp); + $this->assertStringContainsString('https://cdn49.example.com', $resultCsp); + } + + public function testResetClearsAllState(): void + { + $this->nonceGeneratorMock->method('generate')->willReturn('statetestnonce'); + + // First, disable CSP and verify it's disabled + $this->cspHandler->disableCsp(); + + $removedHeaders = []; + $this->responseMock->method('removeHeader')->willReturnCallback(function ($headerName) use (&$removedHeaders) { + $removedHeaders[] = $headerName; + }); + + $nonces = $this->cspHandler->updateResponseHeaders($this->requestMock, $this->responseMock); + $this->assertSame([], $nonces, 'CSP should be disabled'); + + // Reset should clear all state including the disabled flag + $this->cspHandler->reset(); + + // Create fresh mocks for the second call + $request2 = $this->createMock(RequestInterface::class); + $response2 = $this->createMock(ResponseInterface::class); + + $request2->method('hasHeader')->willReturn(false); + + $setHeaders = []; + $response2->method('hasHeader')->willReturn(false); + $response2->method('setHeader')->willReturnCallback(function ($headerName, $value) use (&$setHeaders) { + $setHeaders[$headerName] = $value; + }); + + // After reset, CSP should be enabled again and generate nonces + $nonces = $this->cspHandler->updateResponseHeaders($request2, $response2); + + $this->assertNotEmpty($nonces, 'CSP should be enabled after reset'); + $this->assertArrayHasKey('csp_script_nonce', $nonces); + $this->assertArrayHasKey('csp_style_nonce', $nonces); + $this->assertSame('statetestnonce', $nonces['csp_script_nonce']); + $this->assertSame('statetestnonce', $nonces['csp_style_nonce']); + } + + public function testHandlesScriptSrcElemAndStyleSrcElemDirectives(): void + { + $this->nonceGeneratorMock->method('generate')->willReturn('elemnonce'); + + $this->requestMock->method('hasHeader')->willReturn(false); + + $headers = []; + $this->responseMock->method('hasHeader')->willReturnCallback(function ($name) use (&$headers) { + return isset($headers[$name]); + }); + $this->responseMock->method('getHeader')->willReturnCallback(function ($name) use (&$headers) { + return $headers[$name] ?? null; + }); + $this->responseMock->method('setHeader')->willReturnCallback(function ($name, $value) use (&$headers) { + $headers[$name] = $value; + }); + $this->responseMock->method('removeHeader')->willReturnCallback(function ($name) use (&$headers) { + unset($headers[$name]); + }); + + // CSP with script-src-elem and style-src-elem (CSP Level 3) + $headers['Content-Security-Policy'] = "script-src-elem 'self'; style-src-elem 'self'"; + + $nonces = $this->cspHandler->updateResponseHeaders($this->requestMock, $this->responseMock); + + $this->assertNotEmpty($nonces); + + $resultCsp = $headers['Content-Security-Policy']; + // Both directives should have nonces added + $this->assertStringContainsString("'nonce-elemnonce'", $resultCsp); + } + + public function testHandlesMultipleCspHeaders(): void + { + $this->nonceGeneratorMock->method('generate')->willReturn('multinonce'); + + $this->requestMock->method('hasHeader')->willReturn(false); + + $headers = []; + $this->responseMock->method('hasHeader')->willReturnCallback(function ($name) use (&$headers) { + return isset($headers[$name]); + }); + $this->responseMock->method('getHeader')->willReturnCallback(function ($name) use (&$headers) { + return $headers[$name] ?? null; + }); + $this->responseMock->method('setHeader')->willReturnCallback(function ($name, $value) use (&$headers) { + $headers[$name] = $value; + }); + $this->responseMock->method('removeHeader')->willReturnCallback(function ($name) use (&$headers) { + unset($headers[$name]); + }); + + // Multiple CSP headers (standard, report-only, and legacy X- prefix) + $headers['Content-Security-Policy'] = "script-src 'self'"; + $headers['Content-Security-Policy-Report-Only'] = "script-src 'self'"; + $headers['X-Content-Security-Policy'] = "script-src 'self'"; + + $nonces = $this->cspHandler->updateResponseHeaders($this->requestMock, $this->responseMock); + + $this->assertNotEmpty($nonces); + + // All three headers should be updated with nonces + $this->assertStringContainsString("'nonce-multinonce'", $headers['Content-Security-Policy']); + $this->assertStringContainsString("'nonce-multinonce'", $headers['Content-Security-Policy-Report-Only']); + $this->assertStringContainsString("'nonce-multinonce'", $headers['X-Content-Security-Policy']); + } + + public function testNoncesPersistAcrossMultipleCalls(): void + { + $callCount = 0; + $this->nonceGeneratorMock->method('generate')->willReturnCallback(function () use (&$callCount) { + return 'nonce'.++$callCount; + }); + + // First call - nonces from request headers + $this->requestMock->method('hasHeader')->willReturnCallback(function ($headerName) { + return \in_array($headerName, ['X-PHPFlasher-Script-Nonce', 'X-PHPFlasher-Style-Nonce']); + }); + $this->requestMock->method('getHeader')->willReturnCallback(function ($headerName) { + return 'X-PHPFlasher-Script-Nonce' === $headerName ? 'existing-script-nonce' : 'existing-style-nonce'; + }); + + $nonces = $this->cspHandler->getNonces($this->requestMock); + + $this->assertSame([ + 'csp_script_nonce' => 'existing-script-nonce', + 'csp_style_nonce' => 'existing-style-nonce', + ], $nonces); + + // The nonces from headers should be used, not generated ones + $this->assertSame(0, $callCount, 'No nonces should be generated when headers exist'); + } } diff --git a/tests/Prime/Http/RequestExtensionTest.php b/tests/Prime/Http/RequestExtensionTest.php index b3bc460d..bc95c9c7 100644 --- a/tests/Prime/Http/RequestExtensionTest.php +++ b/tests/Prime/Http/RequestExtensionTest.php @@ -88,4 +88,118 @@ final class RequestExtensionTest extends TestCase $this->assertSame($expectedFlatMapping, $flatMappingProperty->getValue($extension), 'Mapping should be flattened correctly.'); } + + public function testProcessMultipleMessagesInSingleBag(): void + { + $messages = ['First message', 'Second message', 'Third message']; + + $this->request->expects()->hasSession()->andReturns(true); + $this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type); + $this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? $messages : []); + + // Should flash all three messages + $this->flasher->expects()->flash('success', 'First message')->once(); + $this->flasher->expects()->flash('success', 'Second message')->once(); + $this->flasher->expects()->flash('success', 'Third message')->once(); + $this->request->expects()->forgetType('happy')->once(); + + $extension = new RequestExtension($this->flasher, $this->mapping); + $extension->flash($this->request, $this->response); + } + + public function testProcessMessagesWithSpecialCharacters(): void + { + $specialMessage = ' & "quotes" \'single\' < > &'; + + $this->request->expects()->hasSession()->andReturns(true); + $this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type); + $this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? [$specialMessage] : []); + + $this->flasher->expects()->flash('success', $specialMessage)->once(); + $this->request->expects()->forgetType('happy')->once(); + + $extension = new RequestExtension($this->flasher, $this->mapping); + $extension->flash($this->request, $this->response); + } + + public function testProcessMessagesWithUnicodeContent(): void + { + $unicodeMessages = [ + 'Hello', + 'Bonjour', + 'Hola', + ]; + + $this->request->expects()->hasSession()->andReturns(true); + $this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type); + $this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? $unicodeMessages : []); + + foreach ($unicodeMessages as $message) { + $this->flasher->expects()->flash('success', $message)->once(); + } + $this->request->expects()->forgetType('happy')->once(); + + $extension = new RequestExtension($this->flasher, $this->mapping); + $extension->flash($this->request, $this->response); + } + + public function testProcessEmptyMessageValue(): void + { + $this->request->expects()->hasSession()->andReturns(true); + $this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type); + $this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? [''] : []); + + // Empty string message should still be flashed + $this->flasher->expects()->flash('success', '')->once(); + $this->request->expects()->forgetType('happy')->once(); + + $extension = new RequestExtension($this->flasher, $this->mapping); + $extension->flash($this->request, $this->response); + } + + public function testProcessNullMessageValue(): void + { + $this->request->expects()->hasSession()->andReturns(true); + $this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type); + $this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? [null] : []); + + // Null values in the messages array should be passed through + $this->flasher->expects()->flash('success', null)->once(); + $this->request->expects()->forgetType('happy')->once(); + + $extension = new RequestExtension($this->flasher, $this->mapping); + $extension->flash($this->request, $this->response); + } + + public function testProcessMixedMessageTypes(): void + { + // Messages can include strings, nulls, and empty strings + $mixedMessages = ['Valid message', '', 'Another valid message']; + + $this->request->expects()->hasSession()->andReturns(true); + $this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type); + $this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? $mixedMessages : []); + + $this->flasher->expects()->flash('success', 'Valid message')->once(); + $this->flasher->expects()->flash('success', '')->once(); + $this->flasher->expects()->flash('success', 'Another valid message')->once(); + $this->request->expects()->forgetType('happy')->once(); + + $extension = new RequestExtension($this->flasher, $this->mapping); + $extension->flash($this->request, $this->response); + } + + public function testProcessStringMessageAsArray(): void + { + // getType can return a string which gets cast to array + $this->request->expects()->hasSession()->andReturns(true); + $this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type); + $this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? 'Single string message' : []); + + $this->flasher->expects()->flash('success', 'Single string message')->once(); + $this->request->expects()->forgetType('happy')->once(); + + $extension = new RequestExtension($this->flasher, $this->mapping); + $extension->flash($this->request, $this->response); + } } diff --git a/tests/Prime/Http/ResponseExtensionTest.php b/tests/Prime/Http/ResponseExtensionTest.php index c4f2d692..1f3b9cc2 100644 --- a/tests/Prime/Http/ResponseExtensionTest.php +++ b/tests/Prime/Http/ResponseExtensionTest.php @@ -549,4 +549,284 @@ final class ResponseExtensionTest extends TestCase set_error_handler($previousHandler); } } + + public function testRenderWithUnicodeContent(): void + { + $flasher = \Mockery::mock(FlasherInterface::class); + $cspHandler = \Mockery::mock(ContentSecurityPolicyHandlerInterface::class); + $request = \Mockery::mock(RequestInterface::class); + $response = \Mockery::mock(ResponseInterface::class); + + $unicodeHtmlResponse = '
'; + $contentBefore = ''.HtmlPresenter::BODY_END_PLACE_HOLDER.''; + + $cspHandler->allows()->updateResponseHeaders($request, $response)->andReturn([]); + $flasher->allows()->render('html', [], \Mockery::any())->andReturn($unicodeHtmlResponse); + + $request->allows([ + 'isXmlHttpRequest' => false, + 'isHtmlRequestFormat' => true, + ]); + + $response->allows([ + 'isSuccessful' => true, + 'isHtml' => true, + 'isRedirection' => false, + 'isAttachment' => false, + 'isJson' => false, + 'getContent' => $contentBefore, + 'setContent' => \Mockery::on(function ($content) use ($unicodeHtmlResponse) { + $this->assertStringContainsString($unicodeHtmlResponse, $content); + + return true; + }), + ]); + + $responseExtension = new ResponseExtension($flasher, $cspHandler); + $result = $responseExtension->render($request, $response); + + $this->assertInstanceOf(ResponseInterface::class, $result); + } + + public function testRenderWithSpecialHtmlCharacters(): void + { + $flasher = \Mockery::mock(FlasherInterface::class); + $cspHandler = \Mockery::mock(ContentSecurityPolicyHandlerInterface::class); + $request = \Mockery::mock(RequestInterface::class); + $response = \Mockery::mock(ResponseInterface::class); + + $specialHtmlResponse = '
Notification
'; + $contentBefore = 'Content with & special '.HtmlPresenter::BODY_END_PLACE_HOLDER.''; + + $cspHandler->allows()->updateResponseHeaders($request, $response)->andReturn([]); + $flasher->allows()->render('html', [], \Mockery::any())->andReturn($specialHtmlResponse); + + $request->allows([ + 'isXmlHttpRequest' => false, + 'isHtmlRequestFormat' => true, + ]); + + $response->allows([ + 'isSuccessful' => true, + 'isHtml' => true, + 'isRedirection' => false, + 'isAttachment' => false, + 'isJson' => false, + 'getContent' => $contentBefore, + ]); + + $response->expects('setContent')->once()->with(\Mockery::on(function ($content) use ($specialHtmlResponse) { + $this->assertStringContainsString($specialHtmlResponse, $content); + $this->assertStringContainsString('& special', $content); + + return true; + })); + + $responseExtension = new ResponseExtension($flasher, $cspHandler); + $responseExtension->render($request, $response); + } + + public function testRenderWithVeryLargeResponseBody(): void + { + $flasher = \Mockery::mock(FlasherInterface::class); + $cspHandler = \Mockery::mock(ContentSecurityPolicyHandlerInterface::class); + $request = \Mockery::mock(RequestInterface::class); + $response = \Mockery::mock(ResponseInterface::class); + + $htmlResponse = '
Flasher notification
'; + // Create a large content body (simulate a large HTML page) + $largeContent = str_repeat('

Lorem ipsum dolor sit amet

', 10000); + $contentBefore = ''.$largeContent.HtmlPresenter::BODY_END_PLACE_HOLDER.''; + + $cspHandler->allows()->updateResponseHeaders($request, $response)->andReturn([]); + $flasher->allows()->render('html', [], \Mockery::any())->andReturn($htmlResponse); + + $request->allows([ + 'isXmlHttpRequest' => false, + 'isHtmlRequestFormat' => true, + ]); + + $response->allows([ + 'isSuccessful' => true, + 'isHtml' => true, + 'isRedirection' => false, + 'isAttachment' => false, + 'isJson' => false, + 'getContent' => $contentBefore, + ]); + + $response->expects('setContent')->once()->with(\Mockery::on(function ($content) use ($htmlResponse, $largeContent, $contentBefore) { + $this->assertStringContainsString($htmlResponse, $content); + $this->assertStringContainsString($largeContent, $content); + // Verify content length increased by the HTML response + $this->assertGreaterThan(\strlen($contentBefore), \strlen($content)); + + return true; + })); + + $responseExtension = new ResponseExtension($flasher, $cspHandler); + $responseExtension->render($request, $response); + } + + public function testRenderWithMultiplePlaceholdersUsesLast(): void + { + $flasher = \Mockery::mock(FlasherInterface::class); + $cspHandler = \Mockery::mock(ContentSecurityPolicyHandlerInterface::class); + $request = \Mockery::mock(RequestInterface::class); + $response = \Mockery::mock(ResponseInterface::class); + + $htmlResponse = '
Flasher notification
'; + // Content with multiple placeholders - should use the last one found (BODY_END_PLACE_HOLDER) + $contentBefore = ''.HtmlPresenter::HEAD_END_PLACE_HOLDER.'content'.HtmlPresenter::BODY_END_PLACE_HOLDER.''; + + $cspHandler->allows()->updateResponseHeaders($request, $response)->andReturn([]); + $flasher->allows()->render('html', [], \Mockery::any())->andReturn($htmlResponse); + + $request->allows([ + 'isXmlHttpRequest' => false, + 'isHtmlRequestFormat' => true, + ]); + + $response->allows([ + 'isSuccessful' => true, + 'isHtml' => true, + 'isRedirection' => false, + 'isAttachment' => false, + 'isJson' => false, + 'getContent' => $contentBefore, + ]); + + $response->expects('setContent')->once()->with(\Mockery::on(function ($content) use ($htmlResponse) { + // The injection should happen at the last placeholder found (strripos) + // Based on the code, it iterates through placeholders and uses the last position found + $this->assertStringContainsString($htmlResponse, $content); + + return true; + })); + + $responseExtension = new ResponseExtension($flasher, $cspHandler); + $responseExtension->render($request, $response); + } + + public function testRenderPreservesContentEncoding(): void + { + $flasher = \Mockery::mock(FlasherInterface::class); + $cspHandler = \Mockery::mock(ContentSecurityPolicyHandlerInterface::class); + $request = \Mockery::mock(RequestInterface::class); + $response = \Mockery::mock(ResponseInterface::class); + + $htmlResponse = '
Notification
'; + // Content with various encodings and character sets + $contentBefore = 'Content with special chars: © ™  '.HtmlPresenter::BODY_END_PLACE_HOLDER.''; + + $cspHandler->allows()->updateResponseHeaders($request, $response)->andReturn([]); + $flasher->allows()->render('html', [], \Mockery::any())->andReturn($htmlResponse); + + $request->allows([ + 'isXmlHttpRequest' => false, + 'isHtmlRequestFormat' => true, + ]); + + $response->allows([ + 'isSuccessful' => true, + 'isHtml' => true, + 'isRedirection' => false, + 'isAttachment' => false, + 'isJson' => false, + 'getContent' => $contentBefore, + ]); + + $response->expects('setContent')->once()->with(\Mockery::on(function ($content) { + // Verify HTML entities are preserved + $this->assertStringContainsString('©', $content); + $this->assertStringContainsString('™', $content); + $this->assertStringContainsString(' ', $content); + $this->assertStringContainsString('charset="UTF-8"', $content); + + return true; + })); + + $responseExtension = new ResponseExtension($flasher, $cspHandler); + $responseExtension->render($request, $response); + } + + public function testRenderWithEmptyBody(): void + { + $flasher = \Mockery::mock(FlasherInterface::class); + $cspHandler = \Mockery::mock(ContentSecurityPolicyHandlerInterface::class); + $request = \Mockery::mock(RequestInterface::class); + $response = \Mockery::mock(ResponseInterface::class); + + // Empty body but with placeholder + $contentBefore = HtmlPresenter::BODY_END_PLACE_HOLDER; + $htmlResponse = '
Flasher
'; + + $cspHandler->allows()->updateResponseHeaders($request, $response)->andReturn([]); + $flasher->allows()->render('html', [], \Mockery::any())->andReturn($htmlResponse); + + $request->allows([ + 'isXmlHttpRequest' => false, + 'isHtmlRequestFormat' => true, + ]); + + $response->allows([ + 'isSuccessful' => true, + 'isHtml' => true, + 'isRedirection' => false, + 'isAttachment' => false, + 'isJson' => false, + 'getContent' => $contentBefore, + ]); + + $response->expects('setContent')->once()->with(\Mockery::on(function ($content) use ($htmlResponse) { + $this->assertStringContainsString($htmlResponse, $content); + + return true; + })); + + $responseExtension = new ResponseExtension($flasher, $cspHandler); + $responseExtension->render($request, $response); + } + + public function testRenderWithFlasherReplaceMePlaceholder(): void + { + $flasher = \Mockery::mock(FlasherInterface::class); + $cspHandler = \Mockery::mock(ContentSecurityPolicyHandlerInterface::class); + $request = \Mockery::mock(RequestInterface::class); + $response = \Mockery::mock(ResponseInterface::class); + + // Using FLASHER_REPLACE_ME placeholder triggers special handling + $contentBefore = 'content '.HtmlPresenter::FLASHER_REPLACE_ME.' more content'; + $htmlResponse = '{"envelopes":[]}'; + + $cspHandler->allows()->updateResponseHeaders($request, $response)->andReturn([]); + // When FLASHER_REPLACE_ME is used, envelopes_only should be true + $flasher->expects()->render('html', [], \Mockery::on(function ($context) { + return true === $context['envelopes_only']; + }))->once()->andReturn($htmlResponse); + + $request->allows([ + 'isXmlHttpRequest' => false, + 'isHtmlRequestFormat' => true, + ]); + + $response->allows([ + 'isSuccessful' => true, + 'isHtml' => true, + 'isRedirection' => false, + 'isAttachment' => false, + 'isJson' => false, + 'getContent' => $contentBefore, + ]); + + $response->expects('setContent')->once()->with(\Mockery::on(function ($content) { + // Should wrap with options.push() + $this->assertStringContainsString('options.push(', $content); + + return true; + })); + + $responseExtension = new ResponseExtension($flasher, $cspHandler); + $responseExtension->render($request, $response); + } }