Compare commits

...

13 Commits

Author SHA1 Message Date
Younes ENNAJI e0766c1198 Add v2.5.0 release preparation tests and update CHANGELOG 2026-03-07 18:31:05 +00:00
Younes ENNAJI e4337b0b63 Fix SweetAlertBuilder::question() bug and improve code quality 2026-03-07 17:50:43 +00:00
Younes ENNAJI bd8169619e Refactor code and update tests for improved DX 2026-03-02 05:19:34 +00:00
Younes ENNAJI 0d25c72743 Add IDE autocompletion support and Type helper methods 2026-03-02 05:13:28 +00:00
Younes ENNAJI d4abef58eb Standardize exception message format and add exception tests 2026-03-02 04:06:51 +00:00
Younes ENNAJI e126813835 Fix FlasherPlugin::normalizeAlias() return type and add tests 2026-03-02 04:02:08 +00:00
Younes ENNAJI 5612d4d705 Fix ResponseManager filter criteria handling and update tests 2026-03-02 03:49:35 +00:00
Younes ENNAJI 9454b2d155 Fix return type and parameter name inconsistencies in Notification classes 2026-03-02 03:46:43 +00:00
Younes ENNAJI d39159cf90 Fix FilterEvent type consistency and remove redundant callable check 2026-03-02 03:42:27 +00:00
Younes ENNAJI 76d63c03ce Reset CSP handler in worker listeners and add related tests 2026-03-02 03:33:16 +00:00
Younes ENNAJI 286fe5143e Update ContentSecurityPolicy handling and ResponseExtension 2026-03-02 03:27:04 +00:00
Younes ENNAJI f399bc912d Update NotificationFactoryLocator and CHANGELOG 2026-03-02 03:23:45 +00:00
Younes ENNAJI 98336b98bf Update filter criteria and CHANGELOG 2026-03-02 03:20:33 +00:00
67 changed files with 2307 additions and 220 deletions
+40 -4
View File
@@ -1,20 +1,56 @@
# CHANGELOG for 2.x # 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 [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 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 [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 * 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: * 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` - [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` - [Noty] Dispatch events: `flasher:noty:click`, `flasher:noty:close`, `flasher:noty:show`, `flasher:noty:hover`
- [Notyf] Dispatch events: `flasher:notyf:click`, `flasher:notyf:dismiss` - [Notyf] Dispatch events: `flasher:notyf:click`, `flasher:notyf:dismiss`
- [Themes] Dispatch events: `flasher:theme:click` (generic) and `flasher:theme:{name}:click` (specific) - [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 - [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
* fix [Flasher] Add error handling for invalid regex patterns in ResponseExtension::isPathExcluded()
* fix [Flasher] Fix ContentSecurityPolicyHandler parsing of CSP headers with trailing semicolons creating empty directive keys
* fix [Flasher] Add reset() method to ContentSecurityPolicyHandler to fix CSP state leak in long-running processes (Octane, FrankenPHP)
* fix [Flasher] Fix FilterEvent::setFilter() type inconsistency - now accepts FilterInterface instead of concrete Filter class
* fix [Flasher] Remove redundant callable check in FlasherContainer::getContainer()
* fix [Flasher] Fix NotificationStorageMethods::resolveResourceName() return type from ?string to string (never returns null)
* fix [Flasher] Fix parameter name inconsistency in NotificationBuilderInterface::options() - changed $merge to $append to match implementation
* 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] 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 ## [v2.1.3](https://github.com/php-flasher/php-flasher/compare/v2.1.2...v2.1.3) - 2025-01-25
+1 -1
View File
@@ -119,7 +119,7 @@ final class InstallCommand extends Command
$output->writeln('<bg=red;options=bold> ERROR </> An error occurred during the installation of <fg=blue;options=bold>PHPFlasher</> resources.'); $output->writeln('<bg=red;options=bold> ERROR </> An error occurred during the installation of <fg=blue;options=bold>PHPFlasher</> resources.');
} }
$this->assetManager->createManifest(array_merge([], ...$files)); $this->assetManager->createManifest(array_merge(...$files));
$output->writeln(''); $output->writeln('');
@@ -6,6 +6,7 @@ namespace Flasher\Laravel\EventListener;
use Flasher\Laravel\Storage\FallbackSession; use Flasher\Laravel\Storage\FallbackSession;
use Flasher\Prime\EventDispatcher\EventListener\NotificationLoggerListener; use Flasher\Prime\EventDispatcher\EventListener\NotificationLoggerListener;
use Flasher\Prime\Http\Csp\ContentSecurityPolicyHandlerInterface;
use Laravel\Octane\Events\RequestReceived; use Laravel\Octane\Events\RequestReceived;
final readonly class OctaneListener final readonly class OctaneListener
@@ -17,6 +18,11 @@ final readonly class OctaneListener
$listener = $event->sandbox->make('flasher.notification_logger_listener'); $listener = $event->sandbox->make('flasher.notification_logger_listener');
$listener->reset(); $listener->reset();
// Reset the CSP handler to re-enable CSP for new requests
/** @var ContentSecurityPolicyHandlerInterface $cspHandler */
$cspHandler = $event->sandbox->make('flasher.csp_handler');
$cspHandler->reset();
// Reset the fallback session static storage to prevent notification leakage // Reset the fallback session static storage to prevent notification leakage
// when session is not started (e.g., during API requests) // when session is not started (e.g., during API requests)
FallbackSession::reset(); FallbackSession::reset();
+1 -1
View File
@@ -29,7 +29,7 @@
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"illuminate/support": "^11.0|^12.0|^13.0", "illuminate/support": "^11.0|^12.0|^13.0",
"php-flasher/flasher": "^2.4.0" "php-flasher/flasher": "^2.5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+2 -2
View File
@@ -28,8 +28,8 @@
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"php-flasher/flasher-laravel": "^2.4.0", "php-flasher/flasher-laravel": "^2.5.0",
"php-flasher/flasher-noty": "^2.4.0" "php-flasher/flasher-noty": "^2.5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@flasher/flasher-noty", "name": "@flasher/flasher-noty",
"version": "2.4.0", "version": "2.5.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"main": "dist/flasher-noty.cjs.js", "main": "dist/flasher-noty.cjs.js",
@@ -11,7 +11,7 @@
"ncu": "ncu -u" "ncu": "ncu -u"
}, },
"peerDependencies": { "peerDependencies": {
"@flasher/flasher": "^2.4.0", "@flasher/flasher": "^2.5.0",
"noty": "^3.2.0-beta-deprecated" "noty": "^3.2.0-beta-deprecated"
} }
} }
+1 -1
View File
@@ -33,7 +33,7 @@
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"php-flasher/flasher": "^2.4.0" "php-flasher/flasher": "^2.5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+2 -2
View File
@@ -28,8 +28,8 @@
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"php-flasher/flasher-noty": "^2.4.0", "php-flasher/flasher-noty": "^2.5.0",
"php-flasher/flasher-symfony": "^2.4.0" "php-flasher/flasher-symfony": "^2.5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+2 -2
View File
@@ -29,8 +29,8 @@
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"php-flasher/flasher-laravel": "^2.4.0", "php-flasher/flasher-laravel": "^2.5.0",
"php-flasher/flasher-notyf": "^2.4.0" "php-flasher/flasher-notyf": "^2.5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@flasher/flasher-notyf", "name": "@flasher/flasher-notyf",
"version": "2.4.0", "version": "2.5.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"main": "dist/flasher-notyf.cjs.js", "main": "dist/flasher-notyf.cjs.js",
@@ -11,7 +11,7 @@
"ncu": "ncu -u" "ncu": "ncu -u"
}, },
"peerDependencies": { "peerDependencies": {
"@flasher/flasher": "^2.4.0", "@flasher/flasher": "^2.5.0",
"notyf": "^3.10.0" "notyf": "^3.10.0"
} }
} }
+1 -1
View File
@@ -33,7 +33,7 @@
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"php-flasher/flasher": "^2.4.0" "php-flasher/flasher": "^2.5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+2 -2
View File
@@ -29,8 +29,8 @@
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"php-flasher/flasher-notyf": "^2.4.0", "php-flasher/flasher-notyf": "^2.5.0",
"php-flasher/flasher-symfony": "^2.4.0" "php-flasher/flasher-symfony": "^2.5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+58 -1
View File
@@ -9,6 +9,11 @@ use Flasher\Prime\FlasherInterface;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
/** /**
* Service container for PHPFlasher.
*
* Provides a static access point to the flasher services.
* Must be initialized with a PSR-11 container or a closure that returns one.
*
* @internal * @internal
*/ */
final class FlasherContainer final class FlasherContainer
@@ -19,17 +24,56 @@ final class FlasherContainer
{ {
} }
/**
* Initialize the container with a PSR-11 container or a lazy-loading closure.
*
* @param ContainerInterface|\Closure(): ContainerInterface $container
*/
public static function from(ContainerInterface|\Closure $container): void public static function from(ContainerInterface|\Closure $container): void
{ {
self::$instance ??= new self($container); self::$instance ??= new self($container);
} }
/**
* Alias for from() - sets the container instance.
*
* @param FlasherInterface $flasher The flasher instance to use
*/
public static function setContainer(FlasherInterface $flasher): void
{
self::$instance = new self(new class($flasher) implements ContainerInterface {
public function __construct(private readonly FlasherInterface $flasher)
{
}
public function get(string $id): FlasherInterface
{
return $this->flasher;
}
public function has(string $id): bool
{
return 'flasher' === $id;
}
});
}
/**
* Reset the container instance.
*/
public static function reset(): void public static function reset(): void
{ {
self::$instance = null; self::$instance = null;
} }
/** /**
* Create or retrieve a flasher service from the container.
*
* @param string $id The service identifier (e.g., 'flasher', 'flasher.toastr')
*
* @throws \InvalidArgumentException If the service is not found or invalid
* @throws \LogicException If the container has not been initialized
*
* @phpstan-return ($id is 'flasher' ? \Flasher\Prime\FlasherInterface : * @phpstan-return ($id is 'flasher' ? \Flasher\Prime\FlasherInterface :
* ($id is 'flasher.noty' ? \Flasher\Noty\Prime\NotyInterface : * ($id is 'flasher.noty' ? \Flasher\Noty\Prime\NotyInterface :
* ($id is 'flasher.notyf' ? \Flasher\Notyf\Prime\NotyfInterface : * ($id is 'flasher.notyf' ? \Flasher\Notyf\Prime\NotyfInterface :
@@ -52,16 +96,29 @@ final class FlasherContainer
return $factory; return $factory;
} }
/**
* Check if a service exists in the container.
*
* @param string $id The service identifier
*
* @throws \LogicException If the container has not been initialized
*/
public static function has(string $id): bool public static function has(string $id): bool
{ {
return self::getContainer()->has($id); return self::getContainer()->has($id);
} }
/**
* Get the underlying PSR-11 container.
*
* @throws \LogicException If the container has not been initialized
* @throws \InvalidArgumentException If the container closure returns an invalid type
*/
public static function getContainer(): ContainerInterface public static function getContainer(): ContainerInterface
{ {
$container = self::getInstance()->container; $container = self::getInstance()->container;
$resolved = $container instanceof \Closure || \is_callable($container) ? $container() : $container; $resolved = $container instanceof \Closure ? $container() : $container;
if (!$resolved instanceof ContainerInterface) { if (!$resolved instanceof ContainerInterface) {
throw new \InvalidArgumentException(\sprintf('Expected an instance of "%s", got "%s".', ContainerInterface::class, get_debug_type($resolved))); throw new \InvalidArgumentException(\sprintf('Expected an instance of "%s", got "%s".', ContainerInterface::class, get_debug_type($resolved)));
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Flasher\Prime\EventDispatcher\Event; namespace Flasher\Prime\EventDispatcher\Event;
use Flasher\Prime\Notification\Envelope; use Flasher\Prime\Notification\Envelope;
use Flasher\Prime\Storage\Filter\Filter;
use Flasher\Prime\Storage\Filter\FilterInterface; use Flasher\Prime\Storage\Filter\FilterInterface;
final class FilterEvent final class FilterEvent
@@ -26,7 +25,7 @@ final class FilterEvent
return $this->filter; return $this->filter;
} }
public function setFilter(Filter $filter): void public function setFilter(FilterInterface $filter): void
{ {
$this->filter = $filter; $this->filter = $filter;
} }
@@ -11,7 +11,7 @@ final class CriteriaNotRegisteredException extends \Exception
*/ */
public static function create(string $alias, array $availableCriteria = []): self public static function create(string $alias, array $availableCriteria = []): self
{ {
$message = \sprintf('Criteria "%s" is not found, did you forget to register it?', $alias); $message = \sprintf('Criteria "%s" not found, did you forget to register it?', $alias);
if ([] !== $availableCriteria) { if ([] !== $availableCriteria) {
$message .= \sprintf(' Available criteria: [%s]', implode(', ', $availableCriteria)); $message .= \sprintf(' Available criteria: [%s]', implode(', ', $availableCriteria));
@@ -14,7 +14,7 @@ final class PresetNotFoundException extends \Exception
$message = \sprintf('Preset "%s" not found, did you forget to register it?', $preset); $message = \sprintf('Preset "%s" not found, did you forget to register it?', $preset);
if ([] !== $availablePresets) { if ([] !== $availablePresets) {
$message .= \sprintf(' Available presets: "%s"', implode('", "', $availablePresets)); $message .= \sprintf(' Available presets: [%s]', implode(', ', $availablePresets));
} }
return new self($message); return new self($message);
@@ -4,12 +4,46 @@ declare(strict_types=1);
namespace Flasher\Prime\Factory; namespace Flasher\Prime\Factory;
use Flasher\Prime\Notification\Envelope;
use Flasher\Prime\Notification\NotificationBuilderInterface; use Flasher\Prime\Notification\NotificationBuilderInterface;
use Flasher\Prime\Stamp\StampInterface;
/** /**
* Interface for notification factories that create notification builders.
*
* @mixin \Flasher\Prime\Notification\NotificationBuilderInterface * @mixin \Flasher\Prime\Notification\NotificationBuilderInterface
*
* @method $this title(string $title) Set the notification title
* @method $this message(string $message) Set the notification message
* @method $this type(string $type) Set the notification type (success, error, info, warning)
* @method $this options(array<string, mixed> $options, bool $append = true) Set notification options
* @method $this option(string $name, mixed $value) Set a single notification option
* @method $this priority(int $priority) Set the notification priority
* @method $this hops(int $amount) Set the number of requests the notification should persist
* @method $this keep() Keep the notification for one more request
* @method $this delay(int $delay) Set the delay in milliseconds before showing the notification
* @method $this translate(array<string, mixed> $parameters = [], ?string $locale = null) Mark the notification for translation
* @method $this handler(string $handler) Set the notification handler/adapter
* @method $this context(array<string, mixed> $context) Set additional context data
* @method $this when(bool|\Closure $condition) Conditionally show the notification
* @method $this unless(bool|\Closure $condition) Conditionally hide the notification
* @method $this with(StampInterface[]|StampInterface $stamps) Add stamps to the notification
* @method Envelope getEnvelope() Get the notification envelope
* @method Envelope success(string $message, array<string, mixed> $options = [], ?string $title = null) Create a success notification
* @method Envelope error(string $message, array<string, mixed> $options = [], ?string $title = null) Create an error notification
* @method Envelope info(string $message, array<string, mixed> $options = [], ?string $title = null) Create an info notification
* @method Envelope warning(string $message, array<string, mixed> $options = [], ?string $title = null) Create a warning notification
* @method Envelope flash(?string $type = null, ?string $message = null, array<string, mixed> $options = [], ?string $title = null) Create a flash notification
* @method Envelope push() Push the notification to storage
* @method Envelope created(string|object|null $resource = null) Create a "resource created" notification
* @method Envelope updated(string|object|null $resource = null) Create a "resource updated" notification
* @method Envelope saved(string|object|null $resource = null) Create a "resource saved" notification
* @method Envelope deleted(string|object|null $resource = null) Create a "resource deleted" notification
*/ */
interface NotificationFactoryInterface interface NotificationFactoryInterface
{ {
/**
* Create a new notification builder instance.
*/
public function createNotificationBuilder(): NotificationBuilderInterface; public function createNotificationBuilder(): NotificationBuilderInterface;
} }
@@ -15,6 +15,7 @@ final class NotificationFactoryLocator implements NotificationFactoryLocatorInte
/** /**
* @throws FactoryNotFoundException * @throws FactoryNotFoundException
* @throws \InvalidArgumentException
*/ */
public function get(string $id): NotificationFactoryInterface public function get(string $id): NotificationFactoryInterface
{ {
@@ -24,7 +25,15 @@ final class NotificationFactoryLocator implements NotificationFactoryLocatorInte
$factory = $this->factories[$id]; $factory = $this->factories[$id];
return \is_callable($factory) ? $factory() : $factory; if (\is_callable($factory)) {
$factory = $factory();
if (!$factory instanceof NotificationFactoryInterface) {
throw new \InvalidArgumentException(\sprintf('Factory callable for "%s" must return an instance of %s, %s returned.', $id, NotificationFactoryInterface::class, get_debug_type($factory)));
}
}
return $factory;
} }
public function has(string $id): bool public function has(string $id): bool
+1 -1
View File
@@ -18,7 +18,7 @@ final readonly class Flasher implements FlasherInterface
{ {
use ForwardsCalls; use ForwardsCalls;
public const VERSION = '2.4.0'; public const VERSION = '2.5.0';
public function __construct( public function __construct(
private string $default, private string $default,
+31
View File
@@ -5,12 +5,43 @@ declare(strict_types=1);
namespace Flasher\Prime; namespace Flasher\Prime;
use Flasher\Prime\Factory\NotificationFactoryInterface; use Flasher\Prime\Factory\NotificationFactoryInterface;
use Flasher\Prime\Notification\Envelope;
use Flasher\Prime\Response\Presenter\ArrayPresenter; use Flasher\Prime\Response\Presenter\ArrayPresenter;
use Flasher\Prime\Stamp\StampInterface;
/** /**
* Main entry point for creating flash notifications.
*
* @mixin \Flasher\Prime\Notification\NotificationBuilder * @mixin \Flasher\Prime\Notification\NotificationBuilder
* *
* @phpstan-import-type ArrayPresenterType from ArrayPresenter * @phpstan-import-type ArrayPresenterType from ArrayPresenter
*
* @method $this title(string $title) Set the notification title
* @method $this message(string $message) Set the notification message
* @method $this type(string $type) Set the notification type (success, error, info, warning)
* @method $this options(array<string, mixed> $options, bool $append = true) Set notification options
* @method $this option(string $name, mixed $value) Set a single notification option
* @method $this priority(int $priority) Set the notification priority
* @method $this hops(int $amount) Set the number of requests the notification should persist
* @method $this keep() Keep the notification for one more request
* @method $this delay(int $delay) Set the delay in milliseconds before showing the notification
* @method $this translate(array<string, mixed> $parameters = [], ?string $locale = null) Mark the notification for translation
* @method $this handler(string $handler) Set the notification handler/adapter
* @method $this context(array<string, mixed> $context) Set additional context data
* @method $this when(bool|\Closure $condition) Conditionally show the notification
* @method $this unless(bool|\Closure $condition) Conditionally hide the notification
* @method $this with(StampInterface[]|StampInterface $stamps) Add stamps to the notification
* @method Envelope getEnvelope() Get the notification envelope
* @method Envelope success(string $message, array<string, mixed> $options = [], ?string $title = null) Create a success notification
* @method Envelope error(string $message, array<string, mixed> $options = [], ?string $title = null) Create an error notification
* @method Envelope info(string $message, array<string, mixed> $options = [], ?string $title = null) Create an info notification
* @method Envelope warning(string $message, array<string, mixed> $options = [], ?string $title = null) Create a warning notification
* @method Envelope flash(?string $type = null, ?string $message = null, array<string, mixed> $options = [], ?string $title = null) Create a flash notification
* @method Envelope push() Push the notification to storage
* @method Envelope created(string|object|null $resource = null) Create a "resource created" notification
* @method Envelope updated(string|object|null $resource = null) Create a "resource updated" notification
* @method Envelope saved(string|object|null $resource = null) Create a "resource saved" notification
* @method Envelope deleted(string|object|null $resource = null) Create a "resource deleted" notification
*/ */
interface FlasherInterface interface FlasherInterface
{ {
@@ -44,6 +44,11 @@ final class ContentSecurityPolicyHandler implements ContentSecurityPolicyHandler
$this->cspDisabled = true; $this->cspDisabled = true;
} }
public function reset(): void
{
$this->cspDisabled = false;
}
public function updateResponseHeaders(RequestInterface $request, ResponseInterface $response): array public function updateResponseHeaders(RequestInterface $request, ResponseInterface $response): array
{ {
if ($this->cspDisabled) { if ($this->cspDisabled) {
@@ -168,10 +173,13 @@ final class ContentSecurityPolicyHandler implements ContentSecurityPolicyHandler
$directives = []; $directives = [];
foreach (explode(';', $header ?: '') as $directive) { foreach (explode(';', $header ?: '') as $directive) {
$parts = explode(' ', trim($directive)); $directive = trim($directive);
if (\count($parts) < 1) { // @phpstan-ignore-line
if ('' === $directive) {
continue; continue;
} }
$parts = explode(' ', $directive);
$name = array_shift($parts); $name = array_shift($parts);
$directives[$name] = $parts; $directives[$name] = $parts;
} }
@@ -20,4 +20,9 @@ interface ContentSecurityPolicyHandlerInterface
* @return array{csp_script_nonce?: ?string, csp_style_nonce?: ?string} * @return array{csp_script_nonce?: ?string, csp_style_nonce?: ?string}
*/ */
public function updateResponseHeaders(RequestInterface $request, ResponseInterface $response): array; public function updateResponseHeaders(RequestInterface $request, ResponseInterface $response): array;
/**
* Reset the handler state for long-running processes (Octane, FrankenPHP, etc.).
*/
public function reset(): void;
} }
+9 -1
View File
@@ -94,7 +94,15 @@ final readonly class ResponseExtension implements ResponseExtensionInterface
$url = $request->getUri(); $url = $request->getUri();
foreach ($this->excludedPaths as $regexPattern) { foreach ($this->excludedPaths as $regexPattern) {
if (preg_match($regexPattern, $url)) { $result = @preg_match($regexPattern, $url);
if (false === $result) {
trigger_error(\sprintf('Invalid regex pattern "%s" in excluded_paths configuration', $regexPattern), \E_USER_WARNING);
continue;
}
if (1 === $result) {
return true; return true;
} }
} }
@@ -6,18 +6,43 @@ namespace Flasher\Prime\Notification;
use Flasher\Prime\Stamp\StampInterface; use Flasher\Prime\Stamp\StampInterface;
/**
* Builder interface for creating flash notifications.
*
* Provides a fluent API for constructing notifications with various
* properties like title, message, type, and options.
*
* @phpstan-import-type NotificationType from Type
*/
interface NotificationBuilderInterface interface NotificationBuilderInterface
{ {
/**
* Set the notification title.
*
* @param string $title The title to display
*/
public function title(string $title): static; public function title(string $title): static;
/**
* Set the notification message.
*
* @param string $message The message content
*/
public function message(string $message): static; public function message(string $message): static;
/**
* Set the notification type.
*
* @param string $type The notification type (success, error, info, warning)
*
* @phpstan-param NotificationType|string $type
*/
public function type(string $type): static; public function type(string $type): static;
/** /**
* @param array<string, mixed> $options * @param array<string, mixed> $options
*/ */
public function options(array $options, bool $merge = true): static; public function options(array $options, bool $append = true): static;
public function option(string $name, mixed $value): static; public function option(string $name, mixed $value): static;
@@ -117,7 +117,7 @@ trait NotificationStorageMethods
return $envelope; return $envelope;
} }
private function resolveResourceName(object $object): ?string private function resolveResourceName(object $object): string
{ {
$displayName = \is_callable([$object, 'getFlashIdentifier']) ? $object->getFlashIdentifier() : null; $displayName = \is_callable([$object, 'getFlashIdentifier']) ? $object->getFlashIdentifier() : null;
+32
View File
@@ -4,10 +4,42 @@ declare(strict_types=1);
namespace Flasher\Prime\Notification; namespace Flasher\Prime\Notification;
/**
* Notification type constants.
*
* @phpstan-type NotificationType 'success'|'error'|'info'|'warning'
*/
final class Type final class Type
{ {
/** @var 'success' */
public const SUCCESS = 'success'; public const SUCCESS = 'success';
/** @var 'error' */
public const ERROR = 'error'; public const ERROR = 'error';
/** @var 'info' */
public const INFO = 'info'; public const INFO = 'info';
/** @var 'warning' */
public const WARNING = 'warning'; public const WARNING = 'warning';
/**
* Get all available notification types.
*
* @return list<NotificationType>
*/
public static function all(): array
{
return [self::SUCCESS, self::ERROR, self::INFO, self::WARNING];
}
/**
* Check if a given type is valid.
*
* @phpstan-assert-if-true NotificationType $type
*/
public static function isValid(string $type): bool
{
return \in_array($type, self::all(), true);
}
} }
+42 -133
View File
@@ -127,18 +127,24 @@ final class FlasherPlugin extends Plugin
} }
if (!empty($config['scripts'])) { if (!empty($config['scripts'])) {
$config['plugins']['flasher']['scripts'] ??= []; $config['plugins']['flasher']['scripts'] = array_merge(
$config['plugins']['flasher']['scripts'] += $config['scripts']; $config['plugins']['flasher']['scripts'] ?? [],
$config['scripts']
);
} }
if (!empty($config['styles'])) { if (!empty($config['styles'])) {
$config['plugins']['flasher']['styles'] ??= []; $config['plugins']['flasher']['styles'] = array_merge(
$config['plugins']['flasher']['styles'] += $config['styles']; $config['plugins']['flasher']['styles'] ?? [],
$config['styles']
);
} }
if (!empty($config['options'])) { if (!empty($config['options'])) {
$config['plugins']['flasher']['options'] ??= []; $config['plugins']['flasher']['options'] = array_merge(
$config['plugins']['flasher']['options'] += $config['options']; $config['options'],
$config['plugins']['flasher']['options'] ?? []
);
} }
foreach ($config['plugins'] as $name => $options) { foreach ($config['plugins'] as $name => $options) {
@@ -345,7 +351,7 @@ final class FlasherPlugin extends Plugin
return $config; return $config;
} }
$config['flash_bag'] += array_merge($mapping, $config['flash_bag']); $config['flash_bag'] = array_merge($mapping, $config['flash_bag']);
return $config; return $config;
} }
@@ -391,6 +397,25 @@ final class FlasherPlugin extends Plugin
return $config; return $config;
} }
private const DEFAULT_THEME_NAMES = [
'amazon',
'amber',
'jade',
'crystal',
'emerald',
'sapphire',
'ruby',
'onyx',
'neon',
'aurora',
'minimal',
'material',
'google',
'ios',
'slack',
'facebook',
];
/** /**
* @return array<string, array{ * @return array<string, array{
* scripts: string[], * scripts: string[],
@@ -400,135 +425,19 @@ final class FlasherPlugin extends Plugin
*/ */
private function getDefaultThemes(): array private function getDefaultThemes(): array
{ {
return [ $themes = [];
'amazon' => [
'scripts' => ['/vendor/flasher/themes/amazon/amazon.min.js'], foreach (self::DEFAULT_THEME_NAMES as $name) {
$themes[$name] = [
'scripts' => ["/vendor/flasher/themes/{$name}/{$name}.min.js"],
'styles' => [ 'styles' => [
'/vendor/flasher/flasher.min.css', '/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/amazon/amazon.min.css', "/vendor/flasher/themes/{$name}/{$name}.min.css",
], ],
'options' => [], 'options' => [],
], ];
'amber' => [ }
'scripts' => ['/vendor/flasher/themes/amber/amber.min.js'],
'styles' => [ return $themes;
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/amber/amber.min.css',
],
'options' => [],
],
'jade' => [
'scripts' => ['/vendor/flasher/themes/jade/jade.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/jade/jade.min.css',
],
'options' => [],
],
'crystal' => [
'scripts' => ['/vendor/flasher/themes/crystal/crystal.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/crystal/crystal.min.css',
],
'options' => [],
],
'emerald' => [
'scripts' => ['/vendor/flasher/themes/emerald/emerald.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/emerald/emerald.min.css',
],
'options' => [],
],
'sapphire' => [
'scripts' => ['/vendor/flasher/themes/sapphire/sapphire.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/sapphire/sapphire.min.css',
],
'options' => [],
],
'ruby' => [
'scripts' => ['/vendor/flasher/themes/ruby/ruby.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/ruby/ruby.min.css',
],
'options' => [],
],
'onyx' => [
'scripts' => ['/vendor/flasher/themes/onyx/onyx.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/onyx/onyx.min.css',
],
'options' => [],
],
'neon' => [
'scripts' => ['/vendor/flasher/themes/neon/neon.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/neon/neon.min.css',
],
'options' => [],
],
'aurora' => [
'scripts' => ['/vendor/flasher/themes/aurora/aurora.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/aurora/aurora.min.css',
],
'options' => [],
],
'minimal' => [
'scripts' => ['/vendor/flasher/themes/minimal/minimal.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/minimal/minimal.min.css',
],
'options' => [],
],
'material' => [
'scripts' => ['/vendor/flasher/themes/material/material.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/material/material.min.css',
],
'options' => [],
],
'google' => [
'scripts' => ['/vendor/flasher/themes/google/google.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/google/google.min.css',
],
'options' => [],
],
'ios' => [
'scripts' => ['/vendor/flasher/themes/ios/ios.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/ios/ios.min.css',
],
'options' => [],
],
'slack' => [
'scripts' => ['/vendor/flasher/themes/slack/slack.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/slack/slack.min.css',
],
'options' => [],
],
'facebook' => [
'scripts' => ['/vendor/flasher/themes/facebook/facebook.min.js'],
'styles' => [
'/vendor/flasher/flasher.min.css',
'/vendor/flasher/themes/facebook/facebook.min.css',
],
'options' => [],
],
];
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@flasher/flasher", "name": "@flasher/flasher",
"version": "2.4.0", "version": "2.5.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"main": "dist/flasher.cjs.js", "main": "dist/flasher.cjs.js",
+10 -1
View File
@@ -68,6 +68,7 @@ final class ResponseManager implements ResponseManagerInterface
/** /**
* @throws PresenterNotFoundException * @throws PresenterNotFoundException
* @throws \InvalidArgumentException
*/ */
private function createPresenter(string $alias): PresenterInterface private function createPresenter(string $alias): PresenterInterface
{ {
@@ -77,7 +78,15 @@ final class ResponseManager implements ResponseManagerInterface
$presenter = $this->presenters[$alias]; $presenter = $this->presenters[$alias];
return \is_callable($presenter) ? $presenter() : $presenter; if (\is_callable($presenter)) {
$presenter = $presenter();
if (!$presenter instanceof PresenterInterface) {
throw new \InvalidArgumentException(\sprintf('Presenter callable for "%s" must return an instance of %s, %s returned.', $alias, PresenterInterface::class, get_debug_type($presenter)));
}
}
return $presenter;
} }
/** /**
@@ -46,14 +46,9 @@ final readonly class DelayCriteria implements CriteriaInterface
$delay = $stamp->getDelay(); $delay = $stamp->getDelay();
if (null === $this->maxDelay) { $meetsMin = null === $this->minDelay || $delay >= $this->minDelay;
return $delay >= $this->minDelay; $meetsMax = null === $this->maxDelay || $delay <= $this->maxDelay;
}
if ($delay <= $this->maxDelay) { return $meetsMin && $meetsMax;
return $delay >= $this->minDelay;
}
return false;
} }
} }
@@ -11,7 +11,7 @@ final class FilterCriteria implements CriteriaInterface
/** /**
* @var \Closure[] * @var \Closure[]
*/ */
private array $callbacks; private array $callbacks = [];
/** /**
* @throws \InvalidArgumentException * @throws \InvalidArgumentException
@@ -36,11 +36,20 @@ final class FilterCriteria implements CriteriaInterface
* @param Envelope[] $envelopes * @param Envelope[] $envelopes
* *
* @return Envelope[] * @return Envelope[]
*
* @throws \InvalidArgumentException
*/ */
public function apply(array $envelopes): array public function apply(array $envelopes): array
{ {
foreach ($this->callbacks as $callback) { foreach ($this->callbacks as $callback) {
$envelopes = $callback($envelopes); $result = $callback($envelopes);
if (!\is_array($result)) {
throw new \InvalidArgumentException(\sprintf('Filter callback must return an array, got "%s".', get_debug_type($result)));
}
/** @var Envelope[] $result */
$envelopes = $result;
} }
return $envelopes; return $envelopes;
@@ -11,9 +11,9 @@ final readonly class HopsCriteria implements CriteriaInterface
{ {
use RangeExtractor; use RangeExtractor;
private readonly ?int $minAmount; private ?int $minAmount;
private readonly ?int $maxAmount; private ?int $maxAmount;
/** /**
* @throws \InvalidArgumentException * @throws \InvalidArgumentException
@@ -44,14 +44,11 @@ final readonly class HopsCriteria implements CriteriaInterface
return false; return false;
} }
if (null === $this->maxAmount) { $amount = $stamp->getAmount();
return $stamp->getAmount() >= $this->minAmount;
}
if ($stamp->getAmount() <= $this->maxAmount) { $meetsMin = null === $this->minAmount || $amount >= $this->minAmount;
return $stamp->getAmount() >= $this->minAmount; $meetsMax = null === $this->maxAmount || $amount <= $this->maxAmount;
}
return false; return $meetsMin && $meetsMax;
} }
} }
@@ -46,14 +46,9 @@ final readonly class PriorityCriteria implements CriteriaInterface
$priority = $stamp->getPriority(); $priority = $stamp->getPriority();
if (null === $this->maxPriority) { $meetsMin = null === $this->minPriority || $priority >= $this->minPriority;
return $priority >= $this->minPriority; $meetsMax = null === $this->maxPriority || $priority <= $this->maxPriority;
}
if ($priority <= $this->maxPriority) { return $meetsMin && $meetsMax;
return $priority >= $this->minPriority;
}
return false;
} }
} }
+2 -2
View File
@@ -30,8 +30,8 @@
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"php-flasher/flasher-laravel": "^2.4.0", "php-flasher/flasher-laravel": "^2.5.0",
"php-flasher/flasher-sweetalert": "^2.4.0" "php-flasher/flasher-sweetalert": "^2.5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@flasher/flasher-sweetalert", "name": "@flasher/flasher-sweetalert",
"version": "2.4.0", "version": "2.5.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"main": "dist/flasher-sweetalert.cjs.js", "main": "dist/flasher-sweetalert.cjs.js",
@@ -11,7 +11,7 @@
"ncu": "ncu -u" "ncu": "ncu -u"
}, },
"peerDependencies": { "peerDependencies": {
"@flasher/flasher": "^2.4.0", "@flasher/flasher": "^2.5.0",
"sweetalert2": "^11.6.13" "sweetalert2": "^11.6.13"
} }
} }
+6 -4
View File
@@ -152,7 +152,7 @@ final class SweetAlertBuilder extends NotificationBuilder
} }
/** /**
* @phpstan-param array<string, mixed> $options * @phpstan-param OptionsType $options
*/ */
public function question(?string $message = null, array $options = []): self public function question(?string $message = null, array $options = []): self
{ {
@@ -162,7 +162,7 @@ final class SweetAlertBuilder extends NotificationBuilder
$this->messages($message); $this->messages($message);
} }
if ([] === $options) { if ([] !== $options) {
$this->options($options); $this->options($options);
} }
@@ -229,8 +229,9 @@ final class SweetAlertBuilder extends NotificationBuilder
public function showClass(string $showClass, string $value): self public function showClass(string $showClass, string $value): self
{ {
/** @var array<string, string> $option */
$option = $this->getEnvelope()->getOption('showClass', []); $option = $this->getEnvelope()->getOption('showClass', []);
$option[$showClass] = $value; // @phpstan-ignore-line $option[$showClass] = $value;
$this->option('showClass', $option); $this->option('showClass', $option);
@@ -239,8 +240,9 @@ final class SweetAlertBuilder extends NotificationBuilder
public function hideClass(string $hideClass, string $value): self public function hideClass(string $hideClass, string $value): self
{ {
/** @var array<string, string> $option */
$option = $this->getEnvelope()->getOption('hideClass', []); $option = $this->getEnvelope()->getOption('hideClass', []);
$option[$hideClass] = $value; // @phpstan-ignore-line $option[$hideClass] = $value;
$this->option('hideClass', $option); $this->option('hideClass', $option);
+1 -1
View File
@@ -33,7 +33,7 @@
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"php-flasher/flasher": "^2.4.0" "php-flasher/flasher": "^2.5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+2 -2
View File
@@ -30,8 +30,8 @@
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"php-flasher/flasher-sweetalert": "^2.4.0", "php-flasher/flasher-sweetalert": "^2.5.0",
"php-flasher/flasher-symfony": "^2.4.0" "php-flasher/flasher-symfony": "^2.5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+1 -1
View File
@@ -134,7 +134,7 @@ final class InstallCommand extends Command
} }
// Create asset manifest // Create asset manifest
$this->assetManager->createManifest(array_merge([], ...$files)); $this->assetManager->createManifest(array_merge(...$files));
$output->writeln(''); $output->writeln('');
+8 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Flasher\Symfony\EventListener; namespace Flasher\Symfony\EventListener;
use Flasher\Prime\Http\Csp\ContentSecurityPolicyHandlerInterface;
use Flasher\Symfony\Storage\FallbackSession; use Flasher\Symfony\Storage\FallbackSession;
use Symfony\Contracts\Service\ResetInterface; use Symfony\Contracts\Service\ResetInterface;
@@ -14,10 +15,16 @@ use Symfony\Contracts\Service\ResetInterface;
* Tagged with kernel.reset in services.php to be automatically called * Tagged with kernel.reset in services.php to be automatically called
* by Symfony's kernel between requests. * by Symfony's kernel between requests.
*/ */
final class WorkerListener implements ResetInterface final readonly class WorkerListener implements ResetInterface
{ {
public function __construct(
private ContentSecurityPolicyHandlerInterface $cspHandler,
) {
}
public function reset(): void public function reset(): void
{ {
FallbackSession::reset(); FallbackSession::reset();
$this->cspHandler->reset();
} }
} }
@@ -80,6 +80,7 @@ return static function (ContainerConfigurator $container): void {
->tag('kernel.reset', ['method' => 'reset']) ->tag('kernel.reset', ['method' => 'reset'])
->set('flasher.worker_listener', WorkerListener::class) ->set('flasher.worker_listener', WorkerListener::class)
->args([service('flasher.csp_handler')])
->tag('kernel.reset', ['method' => 'reset']) ->tag('kernel.reset', ['method' => 'reset'])
->set('flasher.translation_listener', TranslationListener::class) ->set('flasher.translation_listener', TranslationListener::class)
+1 -1
View File
@@ -28,7 +28,7 @@
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"php-flasher/flasher": "^2.4.0", "php-flasher/flasher": "^2.5.0",
"symfony/config": "^7.0|^8.0", "symfony/config": "^7.0|^8.0",
"symfony/console": "^7.0|^8.0", "symfony/console": "^7.0|^8.0",
"symfony/dependency-injection": "^7.0|^8.0", "symfony/dependency-injection": "^7.0|^8.0",
+2 -2
View File
@@ -29,8 +29,8 @@
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"php-flasher/flasher-laravel": "^2.4.0", "php-flasher/flasher-laravel": "^2.5.0",
"php-flasher/flasher-toastr": "^2.4.0" "php-flasher/flasher-toastr": "^2.5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@flasher/flasher-toastr", "name": "@flasher/flasher-toastr",
"version": "2.4.0", "version": "2.5.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"main": "dist/flasher-toastr.cjs.js", "main": "dist/flasher-toastr.cjs.js",
@@ -11,7 +11,7 @@
"ncu": "ncu -u" "ncu": "ncu -u"
}, },
"peerDependencies": { "peerDependencies": {
"@flasher/flasher": "^2.4.0", "@flasher/flasher": "^2.5.0",
"toastr": "^2.1.4" "toastr": "^2.1.4"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -33,7 +33,7 @@
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"php-flasher/flasher": "^2.4.0" "php-flasher/flasher": "^2.5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+2 -2
View File
@@ -29,8 +29,8 @@
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"php-flasher/flasher-symfony": "^2.4.0", "php-flasher/flasher-symfony": "^2.5.0",
"php-flasher/flasher-toastr": "^2.4.0" "php-flasher/flasher-toastr": "^2.5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+239
View File
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace Flasher\Tests\Prime;
use Flasher\Prime\Configuration;
use PHPUnit\Framework\TestCase;
final class ConfigurationTest extends TestCase
{
public function testFromWithEmptyArray(): void
{
$config = ['default' => '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);
}
}
@@ -71,4 +71,49 @@ final class FlasherContainerTest extends TestCase
FlasherContainer::create('flasher'); FlasherContainer::create('flasher');
} }
public function testFromWithClosureResolvesContainer(): void
{
$flasher = $this->createMock(FlasherInterface::class);
$container = $this->createMock(ContainerInterface::class);
$container->method('has')->willReturn(true);
$container->method('get')->willReturn($flasher);
// Pass a closure that returns the container
FlasherContainer::from(fn () => $container);
$this->assertInstanceOf(FlasherInterface::class, FlasherContainer::create('flasher'));
}
public function testFromWithClosureReturningInvalidTypeThrowsException(): void
{
// Pass a closure that returns something other than ContainerInterface
FlasherContainer::from(fn () => 'not a container'); // @phpstan-ignore argument.type
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Expected an instance of "Psr\Container\ContainerInterface"');
FlasherContainer::getContainer();
}
public function testHasReturnsTrueForExistingService(): void
{
$container = $this->createMock(ContainerInterface::class);
$container->method('has')->with('flasher')->willReturn(true);
FlasherContainer::from($container);
$this->assertTrue(FlasherContainer::has('flasher'));
}
public function testHasReturnsFalseForNonExistingService(): void
{
$container = $this->createMock(ContainerInterface::class);
$container->method('has')->with('nonexistent')->willReturn(false);
FlasherContainer::from($container);
$this->assertFalse(FlasherContainer::has('nonexistent'));
}
} }
@@ -8,6 +8,7 @@ use Flasher\Prime\EventDispatcher\Event\FilterEvent;
use Flasher\Prime\Notification\Envelope; use Flasher\Prime\Notification\Envelope;
use Flasher\Prime\Notification\Notification; use Flasher\Prime\Notification\Notification;
use Flasher\Prime\Storage\Filter\Filter; use Flasher\Prime\Storage\Filter\Filter;
use Flasher\Prime\Storage\Filter\FilterInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class FilterEventTest extends TestCase final class FilterEventTest extends TestCase
@@ -141,4 +142,31 @@ final class FilterEventTest extends TestCase
$this->assertSame('Second', $retrievedEnvelopes[1]->getMessage()); $this->assertSame('Second', $retrievedEnvelopes[1]->getMessage());
$this->assertSame('Third', $retrievedEnvelopes[2]->getMessage()); $this->assertSame('Third', $retrievedEnvelopes[2]->getMessage());
} }
public function testSetFilterAcceptsFilterInterface(): void
{
$originalFilter = new Filter();
$event = new FilterEvent($originalFilter, [], []);
// Create a mock that implements FilterInterface
$mockFilter = $this->createMock(FilterInterface::class);
// setFilter should accept any FilterInterface implementation
$event->setFilter($mockFilter);
$this->assertSame($mockFilter, $event->getFilter());
}
public function testConstructorAcceptsFilterInterface(): void
{
$mockFilter = $this->createMock(FilterInterface::class);
$envelopes = [new Envelope(new Notification())];
$criteria = ['limit' => 5];
$event = new FilterEvent($mockFilter, $envelopes, $criteria);
$this->assertSame($mockFilter, $event->getFilter());
$this->assertSame($envelopes, $event->getEnvelopes());
$this->assertSame($criteria, $event->getCriteria());
}
} }
@@ -54,7 +54,7 @@ final class PresetListenerTest extends TestCase
PresetNotFoundException::class PresetNotFoundException::class
); );
$this->expectExceptionMessage( $this->expectExceptionMessage(
'Preset "entity_deleted" not found, did you forget to register it? Available presets: "entity_saved"' 'Preset "entity_deleted" not found, did you forget to register it? Available presets: [entity_saved]'
); );
$eventDispatcher = new EventDispatcher(); $eventDispatcher = new EventDispatcher();
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Flasher\Tests\Prime\Exception;
use Flasher\Prime\Exception\CriteriaNotRegisteredException;
use PHPUnit\Framework\TestCase;
final class CriteriaNotRegisteredExceptionTest extends TestCase
{
public function testCreateWithAliasOnly(): void
{
$exception = CriteriaNotRegisteredException::create('custom_criteria');
$this->assertSame('Criteria "custom_criteria" not found, did you forget to register it?', $exception->getMessage());
}
public function testCreateWithAvailableCriteria(): void
{
$exception = CriteriaNotRegisteredException::create('custom_criteria', ['limit', 'order_by', 'filter']);
$this->assertSame('Criteria "custom_criteria" not found, did you forget to register it? Available criteria: [limit, order_by, filter]', $exception->getMessage());
}
public function testCreateWithEmptyAvailableCriteria(): void
{
$exception = CriteriaNotRegisteredException::create('custom_criteria', []);
$this->assertSame('Criteria "custom_criteria" not found, did you forget to register it?', $exception->getMessage());
}
public function testIsException(): void
{
$exception = CriteriaNotRegisteredException::create('test');
$this->assertInstanceOf(\Exception::class, $exception);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Flasher\Tests\Prime\Exception;
use Flasher\Prime\Exception\PresenterNotFoundException;
use PHPUnit\Framework\TestCase;
final class PresenterNotFoundExceptionTest extends TestCase
{
public function testCreateWithAliasOnly(): void
{
$exception = PresenterNotFoundException::create('xml');
$this->assertSame('Presenter "xml" not found, did you forget to register it?', $exception->getMessage());
}
public function testCreateWithAvailablePresenters(): void
{
$exception = PresenterNotFoundException::create('xml', ['html', 'json', 'array']);
$this->assertSame('Presenter "xml" not found, did you forget to register it? Available presenters: [html, json, array]', $exception->getMessage());
}
public function testCreateWithEmptyAvailablePresenters(): void
{
$exception = PresenterNotFoundException::create('xml', []);
$this->assertSame('Presenter "xml" not found, did you forget to register it?', $exception->getMessage());
}
public function testIsException(): void
{
$exception = PresenterNotFoundException::create('test');
$this->assertInstanceOf(\Exception::class, $exception);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Flasher\Tests\Prime\Exception;
use Flasher\Prime\Exception\PresetNotFoundException;
use PHPUnit\Framework\TestCase;
final class PresetNotFoundExceptionTest extends TestCase
{
public function testCreateWithPresetOnly(): void
{
$exception = PresetNotFoundException::create('custom_preset');
$this->assertSame('Preset "custom_preset" not found, did you forget to register it?', $exception->getMessage());
}
public function testCreateWithAvailablePresets(): void
{
$exception = PresetNotFoundException::create('custom_preset', ['created', 'updated', 'deleted']);
$this->assertSame('Preset "custom_preset" not found, did you forget to register it? Available presets: [created, updated, deleted]', $exception->getMessage());
}
public function testCreateWithEmptyAvailablePresets(): void
{
$exception = PresetNotFoundException::create('custom_preset', []);
$this->assertSame('Preset "custom_preset" not found, did you forget to register it?', $exception->getMessage());
}
public function testIsException(): void
{
$exception = PresetNotFoundException::create('test');
$this->assertInstanceOf(\Exception::class, $exception);
}
}
@@ -53,4 +53,65 @@ final class NotificationFactoryLocatorTest extends TestCase
$this->assertTrue($notificationFactoryLocator->has('alias')); $this->assertTrue($notificationFactoryLocator->has('alias'));
} }
public function testGetWithCallableFactory(): void
{
$factoryMock = \Mockery::mock(NotificationFactoryInterface::class);
$notificationFactoryLocator = new NotificationFactoryLocator();
$notificationFactoryLocator->addFactory('alias', fn () => $factoryMock);
$retrievedFactory = $notificationFactoryLocator->get('alias');
$this->assertSame($factoryMock, $retrievedFactory);
}
public function testGetWithCallableFactoryCalledEachTime(): void
{
$callCount = 0;
$notificationFactoryLocator = new NotificationFactoryLocator();
$notificationFactoryLocator->addFactory('alias', function () use (&$callCount) {
++$callCount;
return \Mockery::mock(NotificationFactoryInterface::class);
});
$notificationFactoryLocator->get('alias');
$notificationFactoryLocator->get('alias');
$this->assertSame(2, $callCount);
}
public function testGetWithCallableReturningInvalidTypeThrowsException(): void
{
$notificationFactoryLocator = new NotificationFactoryLocator();
$notificationFactoryLocator->addFactory('invalid', fn () => 'not a factory');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Factory callable for "invalid" must return an instance of');
$notificationFactoryLocator->get('invalid');
}
public function testGetWithCallableReturningNullThrowsException(): void
{
$notificationFactoryLocator = new NotificationFactoryLocator();
$notificationFactoryLocator->addFactory('null_factory', fn () => null);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Factory callable for "null_factory" must return an instance of');
$notificationFactoryLocator->get('null_factory');
}
public function testAddFactoryOverwritesExistingFactory(): void
{
$factory1 = \Mockery::mock(NotificationFactoryInterface::class);
$factory2 = \Mockery::mock(NotificationFactoryInterface::class);
$notificationFactoryLocator = new NotificationFactoryLocator();
$notificationFactoryLocator->addFactory('alias', $factory1);
$notificationFactoryLocator->addFactory('alias', $factory2);
$this->assertSame($factory2, $notificationFactoryLocator->get('alias'));
}
} }
+238
View File
@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace Flasher\Tests\Prime;
use Flasher\Prime\Container\FlasherContainer;
use Flasher\Prime\Factory\NotificationFactoryLocatorInterface;
use Flasher\Prime\Flasher;
use Flasher\Prime\FlasherInterface;
use Flasher\Prime\Notification\Envelope;
use Flasher\Prime\Notification\Type;
use Flasher\Prime\Response\ResponseManagerInterface;
use Flasher\Prime\Storage\StorageManagerInterface;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;
use function Flasher\Prime\flash as namespacedFlash;
final class FunctionsTest extends TestCase
{
use MockeryPHPUnitIntegration;
private FlasherInterface $flasher;
protected function setUp(): void
{
parent::setUp();
$factoryLocator = \Mockery::mock(NotificationFactoryLocatorInterface::class);
$responseManager = \Mockery::mock(ResponseManagerInterface::class);
$storageManager = \Mockery::mock(StorageManagerInterface::class);
$storageManager->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 = '<script>alert("XSS")</script> & "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);
}
}
@@ -454,4 +454,313 @@ final class ContentSecurityPolicyHandlerTest extends TestCase
'csp_style_nonce' => 'noresponsenonce', 'csp_style_nonce' => 'noresponsenonce',
], $nonces); ], $nonces);
} }
public function testResetRestoresCspEnabled(): void
{
// First, disable CSP
$this->cspHandler->disableCsp();
// Verify it's disabled by checking updateResponseHeaders returns empty
$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 the handler
$this->cspHandler->reset();
// Now CSP should be enabled again
$this->nonceGeneratorMock->method('generate')->willReturn('resetnonce');
$setHeaders = [];
$this->responseMock->method('setHeader')->willReturnCallback(function ($headerName, $value) use (&$setHeaders) {
$setHeaders[$headerName] = $value;
});
$nonces = $this->cspHandler->updateResponseHeaders($this->requestMock, $this->responseMock);
$this->assertNotEmpty($nonces, 'CSP should be enabled after reset');
$this->assertArrayHasKey('csp_script_nonce', $nonces);
$this->assertArrayHasKey('csp_style_nonce', $nonces);
}
public function testParseDirectivesWithTrailingSemicolons(): void
{
$this->nonceGeneratorMock->method('generate')->willReturn('testnonce');
$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 trailing semicolons and empty directives
$headers['Content-Security-Policy'] = "script-src 'self'; ; style-src 'self'; ";
$nonces = $this->cspHandler->updateResponseHeaders($this->requestMock, $this->responseMock);
// Should parse correctly without creating empty key entries
$this->assertNotEmpty($nonces);
// Verify the resulting CSP header doesn't have empty directives
$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');
}
} }
+114
View File
@@ -88,4 +88,118 @@ final class RequestExtensionTest extends TestCase
$this->assertSame($expectedFlatMapping, $flatMappingProperty->getValue($extension), 'Mapping should be flattened correctly.'); $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 = '<script>alert("XSS")</script> & "quotes" \'single\' < > &amp;';
$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);
}
} }
+371
View File
@@ -458,4 +458,375 @@ final class ResponseExtensionTest extends TestCase
$this->assertSame($response, $result); $this->assertSame($response, $result);
} }
public function testRenderWithInvalidRegexPatternInExcludedPaths(): void
{
$flasher = \Mockery::mock(FlasherInterface::class);
$cspHandler = \Mockery::mock(ContentSecurityPolicyHandlerInterface::class);
$response = \Mockery::mock(ResponseInterface::class);
$htmlResponse = '<div>Flasher</div>';
$contentBefore = 'content '.HtmlPresenter::BODY_END_PLACE_HOLDER;
// Create a concrete request class with getUri method
$request = new class implements RequestInterface {
public function isXmlHttpRequest(): bool
{
return false;
}
public function isHtmlRequestFormat(): bool
{
return true;
}
public function getUri(): string
{
return '/user/profile';
}
public function hasSession(): bool
{
return false;
}
public function isSessionStarted(): bool
{
return false;
}
public function hasType(string $type): bool
{
return false;
}
public function getType(string $type): string|array
{
return [];
}
public function forgetType(string $type): void
{
}
public function hasHeader(string $name): bool
{
return false;
}
public function getHeader(string $name): ?string
{
return null;
}
};
$cspHandler->allows()->updateResponseHeaders($request, $response)->andReturn([]);
$flasher->allows()->render('html', [], \Mockery::any())->andReturn($htmlResponse);
$response->allows([
'isSuccessful' => true,
'isHtml' => true,
'isRedirection' => false,
'isAttachment' => false,
'isJson' => false,
'getContent' => $contentBefore,
'setContent' => \Mockery::any(),
]);
// Test with invalid regex - should trigger warning but continue
$invalidPatterns = ['[invalid regex'];
// Suppress the expected warning
$previousHandler = set_error_handler(fn () => true, \E_USER_WARNING);
try {
$responseExtension = new ResponseExtension($flasher, $cspHandler, $invalidPatterns);
$result = $responseExtension->render($request, $response);
// Should still render since invalid regex is skipped
$this->assertInstanceOf(ResponseInterface::class, $result);
} finally {
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 = '<div></div>';
$contentBefore = '<!DOCTYPE html><html><head></head><body>'.HtmlPresenter::BODY_END_PLACE_HOLDER.'</body></html>';
$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 = '<div class="flasher" data-message="Test &amp; &lt;script&gt;">Notification</div>';
$contentBefore = '<html><body>Content with &amp; special <characters>'.HtmlPresenter::BODY_END_PLACE_HOLDER.'</body></html>';
$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('&amp; 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 = '<div>Flasher notification</div>';
// Create a large content body (simulate a large HTML page)
$largeContent = str_repeat('<p>Lorem ipsum dolor sit amet</p>', 10000);
$contentBefore = '<html><body>'.$largeContent.HtmlPresenter::BODY_END_PLACE_HOLDER.'</body></html>';
$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 = '<div>Flasher notification</div>';
// Content with multiple placeholders - should use the last one found (BODY_END_PLACE_HOLDER)
$contentBefore = '<html>'.HtmlPresenter::HEAD_END_PLACE_HOLDER.'<body>content'.HtmlPresenter::BODY_END_PLACE_HOLDER.'</body></html>';
$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 = '<div>Notification</div>';
// Content with various encodings and character sets
$contentBefore = '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"></head><body>Content with special chars: &copy; &trade; &nbsp;'.HtmlPresenter::BODY_END_PLACE_HOLDER.'</body></html>';
$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('&copy;', $content);
$this->assertStringContainsString('&trade;', $content);
$this->assertStringContainsString('&nbsp;', $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 = '<div>Flasher</div>';
$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);
}
} }
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Flasher\Tests\Prime\Notification;
use Flasher\Prime\Notification\Type;
use PHPUnit\Framework\TestCase;
final class TypeTest extends TestCase
{
public function testConstants(): void
{
$this->assertSame('success', Type::SUCCESS);
$this->assertSame('error', Type::ERROR);
$this->assertSame('info', Type::INFO);
$this->assertSame('warning', Type::WARNING);
}
public function testAll(): void
{
$expected = ['success', 'error', 'info', 'warning'];
$this->assertSame($expected, Type::all());
}
#[\PHPUnit\Framework\Attributes\DataProvider('validTypesProvider')]
public function testIsValidWithValidTypes(string $type): void
{
$this->assertTrue(Type::isValid($type));
}
/**
* @return iterable<string, array{string}>
*/
public static function validTypesProvider(): iterable
{
yield 'success' => ['success'];
yield 'error' => ['error'];
yield 'info' => ['info'];
yield 'warning' => ['warning'];
}
#[\PHPUnit\Framework\Attributes\DataProvider('invalidTypesProvider')]
public function testIsValidWithInvalidTypes(string $type): void
{
$this->assertFalse(Type::isValid($type));
}
/**
* @return iterable<string, array{string}>
*/
public static function invalidTypesProvider(): iterable
{
yield 'invalid' => ['invalid'];
yield 'empty' => [''];
yield 'uppercase SUCCESS' => ['SUCCESS'];
yield 'notice' => ['notice'];
}
}
+53
View File
@@ -406,4 +406,57 @@ final class FlasherPluginTest extends TestCase
$plugin = new FlasherPlugin(); $plugin = new FlasherPlugin();
$this->assertSame([], $plugin->getOptions()); $this->assertSame([], $plugin->getOptions());
} }
public function testNormalizeConfigMergesTopLevelAndPluginLevelScripts(): void
{
$plugin = new FlasherPlugin();
$config = $plugin->normalizeConfig([
'scripts' => ['/top-level.js'],
'plugins' => [
'flasher' => [
'scripts' => ['/plugin-level.js'],
],
],
]);
// Both scripts should be present - plugin-level first, then top-level
$this->assertCount(2, $config['plugins']['flasher']['scripts']);
$this->assertContains('/plugin-level.js', $config['plugins']['flasher']['scripts']);
$this->assertContains('/top-level.js', $config['plugins']['flasher']['scripts']);
}
public function testNormalizeConfigMergesTopLevelAndPluginLevelStyles(): void
{
$plugin = new FlasherPlugin();
$config = $plugin->normalizeConfig([
'styles' => ['/top-level.css'],
'plugins' => [
'flasher' => [
'styles' => ['/plugin-level.css'],
],
],
]);
// Both styles should be present
$this->assertContains('/plugin-level.css', $config['plugins']['flasher']['styles']);
$this->assertContains('/top-level.css', $config['plugins']['flasher']['styles']);
}
public function testNormalizeConfigMergesOptionsWithPluginLevelOverride(): void
{
$plugin = new FlasherPlugin();
$config = $plugin->normalizeConfig([
'options' => ['timeout' => 5000, 'position' => 'top-right'],
'plugins' => [
'flasher' => [
'options' => ['timeout' => 3000],
],
],
]);
// Plugin-level timeout should override top-level
$this->assertSame(3000, $config['plugins']['flasher']['options']['timeout']);
// Top-level position should be preserved as default
$this->assertSame('top-right', $config['plugins']['flasher']['options']['position']);
}
} }
@@ -169,4 +169,58 @@ final class ResponseManagerTest extends TestCase
}); });
JAVASCRIPT; JAVASCRIPT;
} }
public function testAddPresenterWithCallable(): void
{
$resourceManager = $this->createMock(ResourceManagerInterface::class);
$resourceManager->method('populateResponse')->willReturnArgument(0);
$storageManager = $this->createMock(StorageManagerInterface::class);
$eventDispatcher = $this->createMock(EventDispatcherInterface::class);
$responseManager = new ResponseManager($resourceManager, $storageManager, $eventDispatcher);
$customPresenter = new \Flasher\Prime\Response\Presenter\ArrayPresenter();
$responseManager->addPresenter('custom', fn () => $customPresenter);
$result = $responseManager->render('custom');
$this->assertIsArray($result);
}
public function testAddPresenterWithCallableReturningInvalidTypeThrowsException(): void
{
$resourceManager = $this->createMock(ResourceManagerInterface::class);
$resourceManager->method('populateResponse')->willReturnArgument(0);
$storageManager = $this->createMock(StorageManagerInterface::class);
$eventDispatcher = $this->createMock(EventDispatcherInterface::class);
$responseManager = new ResponseManager($resourceManager, $storageManager, $eventDispatcher);
$responseManager->addPresenter('invalid', fn () => 'not a presenter');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Presenter callable for "invalid" must return an instance of');
$responseManager->render('invalid');
}
public function testAddPresenterWithDirectInstance(): void
{
$resourceManager = $this->createMock(ResourceManagerInterface::class);
$resourceManager->method('populateResponse')->willReturnArgument(0);
$storageManager = $this->createMock(StorageManagerInterface::class);
$eventDispatcher = $this->createMock(EventDispatcherInterface::class);
$responseManager = new ResponseManager($resourceManager, $storageManager, $eventDispatcher);
$customPresenter = new \Flasher\Prime\Response\Presenter\ArrayPresenter();
$responseManager->addPresenter('direct', $customPresenter);
$result = $responseManager->render('direct');
$this->assertIsArray($result);
}
} }
@@ -248,4 +248,52 @@ final class DelayCriteriaTest extends TestCase
$this->assertCount(3, $result); $this->assertCount(3, $result);
} }
public function testMatchWithOnlyMaxAndNullMin(): void
{
// This test verifies explicit null handling for min
// Previously relied on PHP's implicit null-to-0 coercion
$envelope = new Envelope(new Notification(), [new DelayStamp(3)]);
$criteria = new DelayCriteria(['max' => 5]);
// With min=null and max=5, delay=3 should match
$this->assertTrue($criteria->match($envelope));
}
public function testMatchWithOnlyMaxRejectsHigherDelay(): void
{
$envelope = new Envelope(new Notification(), [new DelayStamp(10)]);
$criteria = new DelayCriteria(['max' => 5]);
// With min=null and max=5, delay=10 should NOT match
$this->assertFalse($criteria->match($envelope));
}
public function testMatchWithBothNullMinAndMax(): void
{
// When both min and max are null, all envelopes with delay stamp should match
$envelope = new Envelope(new Notification(), [new DelayStamp(100)]);
$criteria = new DelayCriteria([]);
$this->assertTrue($criteria->match($envelope));
}
public function testApplyWithNullMinAndMaxMatchesAll(): void
{
$envelopes = [
new Envelope(new Notification(), [new DelayStamp(0)]),
new Envelope(new Notification(), [new DelayStamp(50)]),
new Envelope(new Notification(), [new DelayStamp(100)]),
];
$criteria = new DelayCriteria([]);
$result = $criteria->apply($envelopes);
// All envelopes with delay stamp should match when no constraints
$this->assertCount(3, $result);
}
} }
@@ -166,4 +166,62 @@ final class FilterCriteriaTest extends TestCase
$this->assertSame(['first', 'second'], $order); $this->assertSame(['first', 'second'], $order);
$this->assertCount(2, $result); $this->assertCount(2, $result);
} }
public function testConstructorWithEmptyArrayDoesNotThrow(): void
{
// This test verifies the fix for uninitialized $callbacks property
// Previously, new FilterCriteria([]) would leave $callbacks uninitialized
// causing "must not be accessed before initialization" error on apply()
$criteria = new FilterCriteria([]);
$envelopes = [new Envelope(new Notification())];
$result = $criteria->apply($envelopes);
// With no callbacks, envelopes should pass through unchanged
$this->assertSame($envelopes, $result);
}
public function testApplyWithEmptyCallbacksReturnsEnvelopesUnchanged(): void
{
$criteria = new FilterCriteria([]);
$notification = new Notification();
$notification->setMessage('test');
$envelopes = [new Envelope($notification)];
$result = $criteria->apply($envelopes);
$this->assertCount(1, $result);
$this->assertSame('test', $result[0]->getMessage());
}
public function testApplyThrowsExceptionWhenCallbackReturnsNonArray(): void
{
$criteria = new FilterCriteria(fn ($e) => 'not an array');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Filter callback must return an array, got "string".');
$criteria->apply([new Envelope(new Notification())]);
}
public function testApplyThrowsExceptionWhenCallbackReturnsNull(): void
{
$criteria = new FilterCriteria(fn ($e) => null);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Filter callback must return an array, got "null".');
$criteria->apply([new Envelope(new Notification())]);
}
public function testApplyThrowsExceptionWhenCallbackReturnsObject(): void
{
$criteria = new FilterCriteria(fn ($e) => new \stdClass());
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Filter callback must return an array, got "stdClass".');
$criteria->apply([new Envelope(new Notification())]);
}
} }
@@ -229,4 +229,52 @@ final class HopsCriteriaTest extends TestCase
$this->assertCount(1, $result); $this->assertCount(1, $result);
} }
public function testMatchWithOnlyMaxAndNullMin(): void
{
// This test verifies explicit null handling for min
// Previously relied on PHP's implicit null-to-0 coercion
$envelope = new Envelope(new Notification(), [new HopsStamp(2)]);
$criteria = new HopsCriteria(['max' => 5]);
// With min=null and max=5, hops=2 should match
$this->assertTrue($criteria->match($envelope));
}
public function testMatchWithOnlyMaxRejectsHigherHops(): void
{
$envelope = new Envelope(new Notification(), [new HopsStamp(10)]);
$criteria = new HopsCriteria(['max' => 5]);
// With min=null and max=5, hops=10 should NOT match
$this->assertFalse($criteria->match($envelope));
}
public function testMatchWithBothNullMinAndMax(): void
{
// When both min and max are null, all envelopes with hops stamp should match
$envelope = new Envelope(new Notification(), [new HopsStamp(100)]);
$criteria = new HopsCriteria([]);
$this->assertTrue($criteria->match($envelope));
}
public function testApplyWithNullMinAndMaxMatchesAll(): void
{
$envelopes = [
new Envelope(new Notification(), [new HopsStamp(1)]),
new Envelope(new Notification(), [new HopsStamp(50)]),
new Envelope(new Notification(), [new HopsStamp(100)]),
];
$criteria = new HopsCriteria([]);
$result = $criteria->apply($envelopes);
// All envelopes with hops stamp should match when no constraints
$this->assertCount(3, $result);
}
} }
@@ -245,4 +245,52 @@ final class PriorityCriteriaTest extends TestCase
$this->assertCount(1, $result); $this->assertCount(1, $result);
} }
public function testMatchWithOnlyMaxAndNullMin(): void
{
// This test verifies explicit null handling for min
// Previously relied on PHP's implicit null-to-0 coercion
$envelope = new Envelope(new Notification(), [new PriorityStamp(3)]);
$criteria = new PriorityCriteria(['max' => 5]);
// With min=null and max=5, priority 3 should match
$this->assertTrue($criteria->match($envelope));
}
public function testMatchWithOnlyMaxRejectsHigherPriority(): void
{
$envelope = new Envelope(new Notification(), [new PriorityStamp(10)]);
$criteria = new PriorityCriteria(['max' => 5]);
// With min=null and max=5, priority 10 should NOT match
$this->assertFalse($criteria->match($envelope));
}
public function testMatchWithBothNullMinAndMax(): void
{
// When both min and max are null, all envelopes with priority stamp should match
$envelope = new Envelope(new Notification(), [new PriorityStamp(100)]);
$criteria = new PriorityCriteria([]);
$this->assertTrue($criteria->match($envelope));
}
public function testApplyWithNullMinAndMaxMatchesAll(): void
{
$envelopes = [
new Envelope(new Notification(), [new PriorityStamp(-100)]),
new Envelope(new Notification(), [new PriorityStamp(0)]),
new Envelope(new Notification(), [new PriorityStamp(100)]),
];
$criteria = new PriorityCriteria([]);
$result = $criteria->apply($envelopes);
// All envelopes with priority stamp should match when no constraints
$this->assertCount(3, $result);
}
} }
@@ -29,7 +29,7 @@ final class SweetAlertBuilderTest extends TestCase
$envelope = $this->sweetAlertBuilder->getEnvelope(); $envelope = $this->sweetAlertBuilder->getEnvelope();
$options = $envelope->getNotification()->getOptions(); $options = $envelope->getNotification()->getOptions();
$this->assertSame(['showCancelButton' => true, 'text' => 'Are you sure?'], $options); $this->assertSame(['showCancelButton' => true, 'text' => 'Are you sure?', 'option1' => 'value1'], $options);
} }
public function testTitle(): void public function testTitle(): void
@@ -4,13 +4,23 @@ declare(strict_types=1);
namespace Flasher\Tests\Symfony\EventListener; namespace Flasher\Tests\Symfony\EventListener;
use Flasher\Prime\Http\Csp\ContentSecurityPolicyHandlerInterface;
use Flasher\Symfony\EventListener\WorkerListener; use Flasher\Symfony\EventListener\WorkerListener;
use Flasher\Symfony\Storage\FallbackSession; use Flasher\Symfony\Storage\FallbackSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Service\ResetInterface; use Symfony\Contracts\Service\ResetInterface;
final class WorkerListenerTest extends TestCase final class WorkerListenerTest extends TestCase
{ {
/** @var MockObject&ContentSecurityPolicyHandlerInterface */
private MockObject $cspHandler;
protected function setUp(): void
{
$this->cspHandler = $this->createMock(ContentSecurityPolicyHandlerInterface::class);
}
protected function tearDown(): void protected function tearDown(): void
{ {
FallbackSession::reset(); FallbackSession::reset();
@@ -18,7 +28,7 @@ final class WorkerListenerTest extends TestCase
public function testListenerImplementsResetInterface(): void public function testListenerImplementsResetInterface(): void
{ {
$listener = new WorkerListener(); $listener = new WorkerListener($this->cspHandler);
$this->assertInstanceOf(ResetInterface::class, $listener); $this->assertInstanceOf(ResetInterface::class, $listener);
} }
@@ -32,8 +42,11 @@ final class WorkerListenerTest extends TestCase
// Verify data is stored // Verify data is stored
$this->assertSame(['test_envelope'], $fallbackSession->get('flasher::envelopes')); $this->assertSame(['test_envelope'], $fallbackSession->get('flasher::envelopes'));
// Mock CSP handler to expect reset call
$this->cspHandler->expects($this->once())->method('reset');
// Reset via WorkerListener // Reset via WorkerListener
$listener = new WorkerListener(); $listener = new WorkerListener($this->cspHandler);
$listener->reset(); $listener->reset();
// Verify FallbackSession was reset // Verify FallbackSession was reset
@@ -42,7 +55,9 @@ final class WorkerListenerTest extends TestCase
public function testResetIsIdempotent(): void public function testResetIsIdempotent(): void
{ {
$listener = new WorkerListener(); $this->cspHandler->expects($this->exactly(3))->method('reset');
$listener = new WorkerListener($this->cspHandler);
// Should not throw when called multiple times // Should not throw when called multiple times
$listener->reset(); $listener->reset();
@@ -51,4 +66,12 @@ final class WorkerListenerTest extends TestCase
$this->assertTrue(true); $this->assertTrue(true);
} }
public function testResetCallsCspHandlerReset(): void
{
$this->cspHandler->expects($this->once())->method('reset');
$listener = new WorkerListener($this->cspHandler);
$listener->reset();
}
} }