mirror of
https://github.com/php-flasher/php-flasher.git
synced 2026-03-31 15:07:47 +01:00
Compare commits
13 Commits
50ffa722a5
...
e0766c1198
| Author | SHA1 | Date | |
|---|---|---|---|
| e0766c1198 | |||
| e4337b0b63 | |||
| bd8169619e | |||
| 0d25c72743 | |||
| d4abef58eb | |||
| e126813835 | |||
| 5612d4d705 | |||
| 9454b2d155 | |||
| d39159cf90 | |||
| 76d63c03ce | |||
| 286fe5143e | |||
| f399bc912d | |||
| 98336b98bf |
+40
-4
@@ -1,20 +1,56 @@
|
||||
# CHANGELOG for 2.x
|
||||
|
||||
## [Unreleased](https://github.com/php-flasher/php-flasher/compare/v2.1.4...2.x)
|
||||
## [Unreleased](https://github.com/php-flasher/php-flasher/compare/v2.2.0...2.x)
|
||||
|
||||
## [v2.5.0](https://github.com/php-flasher/php-flasher/compare/v2.1.4...v2.2.0) - 2026-03-07
|
||||
|
||||
### Added
|
||||
|
||||
* feature [Laravel] Improve Laravel Octane support by resetting FallbackSession static storage between requests to prevent notification leakage
|
||||
* feature [Symfony] Add FrankenPHP/Swoole/RoadRunner support with WorkerListener that implements ResetInterface and is tagged with kernel.reset
|
||||
* feature [Symfony] Add reset() method to FallbackSession for long-running process support
|
||||
* feature [Flasher] Add Hotwire/Turbo Drive support with turbo:before-cache event listener to clean up notifications before page caching
|
||||
* fix [Flasher] Fix potential runtime error in Envelope::toArray() when no PresentableStampInterface stamps exist
|
||||
* fix [Flasher] Use more specific \Random\RandomException in IdStamp instead of broad \Exception
|
||||
* fix [Flasher] Update Livewire navigation cleanup to use correct .fl-wrapper selector instead of unused .fl-no-cache class
|
||||
* feature [Flasher] Add event dispatching system for all notification adapters and themes with Livewire integration:
|
||||
- [Toastr] Dispatch events: `flasher:toastr:click`, `flasher:toastr:close`, `flasher:toastr:show`, `flasher:toastr:hidden`
|
||||
- [Noty] Dispatch events: `flasher:noty:click`, `flasher:noty:close`, `flasher:noty:show`, `flasher:noty:hover`
|
||||
- [Notyf] Dispatch events: `flasher:notyf:click`, `flasher:notyf:dismiss`
|
||||
- [Themes] Dispatch events: `flasher:theme:click` (generic) and `flasher:theme:{name}:click` (specific)
|
||||
- [Laravel] Add LivewireListener classes for all adapters and themes to enable Livewire event handling
|
||||
* feature [Flasher] Add 16 notification themes: Amazon, Amber, Jade, Crystal, and more
|
||||
* feature [DX] Add `@method` annotations to FlasherInterface and NotificationFactoryInterface for better IDE autocompletion
|
||||
* feature [DX] Add Type::all() and Type::isValid() helper methods with PHPStan type narrowing
|
||||
* feature [DX] Add `@throws` annotations to FlasherContainer methods for better exception documentation
|
||||
* feature [DX] Add FlasherContainer::setContainer() method as convenient alias for testing
|
||||
* feature [DX] Add PHPStan type alias `NotificationType` for valid notification types
|
||||
|
||||
### Fixed
|
||||
|
||||
* fix [SweetAlert] Fix SweetAlertBuilder::question() bug where options parameter was being ignored
|
||||
* fix [Flasher] Fix potential runtime error in Envelope::toArray() when no PresentableStampInterface stamps exist
|
||||
* fix [Flasher] Use more specific \Random\RandomException in IdStamp instead of broad \Exception
|
||||
* fix [Flasher] Update Livewire navigation cleanup to use correct .fl-wrapper selector instead of unused .fl-no-cache class
|
||||
* fix [Flasher] Fix FilterCriteria uninitialized property error when constructed with empty array
|
||||
* fix [Flasher] Fix null comparison issues in PriorityCriteria, HopsCriteria, and DelayCriteria that relied on PHP's implicit null-to-0 coercion
|
||||
* fix [Flasher] Add type validation for callable factory return values in NotificationFactoryLocator with descriptive error messages
|
||||
* 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
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
$this->assetManager->createManifest(array_merge([], ...$files));
|
||||
$this->assetManager->createManifest(array_merge(...$files));
|
||||
|
||||
$output->writeln('');
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Flasher\Laravel\EventListener;
|
||||
|
||||
use Flasher\Laravel\Storage\FallbackSession;
|
||||
use Flasher\Prime\EventDispatcher\EventListener\NotificationLoggerListener;
|
||||
use Flasher\Prime\Http\Csp\ContentSecurityPolicyHandlerInterface;
|
||||
use Laravel\Octane\Events\RequestReceived;
|
||||
|
||||
final readonly class OctaneListener
|
||||
@@ -17,6 +18,11 @@ final readonly class OctaneListener
|
||||
$listener = $event->sandbox->make('flasher.notification_logger_listener');
|
||||
$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
|
||||
// when session is not started (e.g., during API requests)
|
||||
FallbackSession::reset();
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"illuminate/support": "^11.0|^12.0|^13.0",
|
||||
"php-flasher/flasher": "^2.4.0"
|
||||
"php-flasher/flasher": "^2.5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php-flasher/flasher-laravel": "^2.4.0",
|
||||
"php-flasher/flasher-noty": "^2.4.0"
|
||||
"php-flasher/flasher-laravel": "^2.5.0",
|
||||
"php-flasher/flasher-noty": "^2.5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@flasher/flasher-noty",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"main": "dist/flasher-noty.cjs.js",
|
||||
@@ -11,7 +11,7 @@
|
||||
"ncu": "ncu -u"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@flasher/flasher": "^2.4.0",
|
||||
"@flasher/flasher": "^2.5.0",
|
||||
"noty": "^3.2.0-beta-deprecated"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php-flasher/flasher": "^2.4.0"
|
||||
"php-flasher/flasher": "^2.5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php-flasher/flasher-noty": "^2.4.0",
|
||||
"php-flasher/flasher-symfony": "^2.4.0"
|
||||
"php-flasher/flasher-noty": "^2.5.0",
|
||||
"php-flasher/flasher-symfony": "^2.5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php-flasher/flasher-laravel": "^2.4.0",
|
||||
"php-flasher/flasher-notyf": "^2.4.0"
|
||||
"php-flasher/flasher-laravel": "^2.5.0",
|
||||
"php-flasher/flasher-notyf": "^2.5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@flasher/flasher-notyf",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"main": "dist/flasher-notyf.cjs.js",
|
||||
@@ -11,7 +11,7 @@
|
||||
"ncu": "ncu -u"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@flasher/flasher": "^2.4.0",
|
||||
"@flasher/flasher": "^2.5.0",
|
||||
"notyf": "^3.10.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php-flasher/flasher": "^2.4.0"
|
||||
"php-flasher/flasher": "^2.5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php-flasher/flasher-notyf": "^2.4.0",
|
||||
"php-flasher/flasher-symfony": "^2.4.0"
|
||||
"php-flasher/flasher-notyf": "^2.5.0",
|
||||
"php-flasher/flasher-symfony": "^2.5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -9,6 +9,11 @@ use Flasher\Prime\FlasherInterface;
|
||||
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
|
||||
*/
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
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 :
|
||||
* ($id is 'flasher.noty' ? \Flasher\Noty\Prime\NotyInterface :
|
||||
* ($id is 'flasher.notyf' ? \Flasher\Notyf\Prime\NotyfInterface :
|
||||
@@ -52,16 +96,29 @@ final class FlasherContainer
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
$container = self::getInstance()->container;
|
||||
|
||||
$resolved = $container instanceof \Closure || \is_callable($container) ? $container() : $container;
|
||||
$resolved = $container instanceof \Closure ? $container() : $container;
|
||||
|
||||
if (!$resolved instanceof ContainerInterface) {
|
||||
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;
|
||||
|
||||
use Flasher\Prime\Notification\Envelope;
|
||||
use Flasher\Prime\Storage\Filter\Filter;
|
||||
use Flasher\Prime\Storage\Filter\FilterInterface;
|
||||
|
||||
final class FilterEvent
|
||||
@@ -26,7 +25,7 @@ final class FilterEvent
|
||||
return $this->filter;
|
||||
}
|
||||
|
||||
public function setFilter(Filter $filter): void
|
||||
public function setFilter(FilterInterface $filter): void
|
||||
{
|
||||
$this->filter = $filter;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ final class CriteriaNotRegisteredException extends \Exception
|
||||
*/
|
||||
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) {
|
||||
$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);
|
||||
|
||||
if ([] !== $availablePresets) {
|
||||
$message .= \sprintf(' Available presets: "%s"', implode('", "', $availablePresets));
|
||||
$message .= \sprintf(' Available presets: [%s]', implode(', ', $availablePresets));
|
||||
}
|
||||
|
||||
return new self($message);
|
||||
|
||||
@@ -4,12 +4,46 @@ declare(strict_types=1);
|
||||
|
||||
namespace Flasher\Prime\Factory;
|
||||
|
||||
use Flasher\Prime\Notification\Envelope;
|
||||
use Flasher\Prime\Notification\NotificationBuilderInterface;
|
||||
use Flasher\Prime\Stamp\StampInterface;
|
||||
|
||||
/**
|
||||
* Interface for notification factories that create notification builders.
|
||||
*
|
||||
* @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
|
||||
{
|
||||
/**
|
||||
* Create a new notification builder instance.
|
||||
*/
|
||||
public function createNotificationBuilder(): NotificationBuilderInterface;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ final class NotificationFactoryLocator implements NotificationFactoryLocatorInte
|
||||
|
||||
/**
|
||||
* @throws FactoryNotFoundException
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function get(string $id): NotificationFactoryInterface
|
||||
{
|
||||
@@ -24,7 +25,15 @@ final class NotificationFactoryLocator implements NotificationFactoryLocatorInte
|
||||
|
||||
$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
|
||||
|
||||
@@ -18,7 +18,7 @@ final readonly class Flasher implements FlasherInterface
|
||||
{
|
||||
use ForwardsCalls;
|
||||
|
||||
public const VERSION = '2.4.0';
|
||||
public const VERSION = '2.5.0';
|
||||
|
||||
public function __construct(
|
||||
private string $default,
|
||||
|
||||
@@ -5,12 +5,43 @@ declare(strict_types=1);
|
||||
namespace Flasher\Prime;
|
||||
|
||||
use Flasher\Prime\Factory\NotificationFactoryInterface;
|
||||
use Flasher\Prime\Notification\Envelope;
|
||||
use Flasher\Prime\Response\Presenter\ArrayPresenter;
|
||||
use Flasher\Prime\Stamp\StampInterface;
|
||||
|
||||
/**
|
||||
* Main entry point for creating flash notifications.
|
||||
*
|
||||
* @mixin \Flasher\Prime\Notification\NotificationBuilder
|
||||
*
|
||||
* @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
|
||||
{
|
||||
|
||||
@@ -44,6 +44,11 @@ final class ContentSecurityPolicyHandler implements ContentSecurityPolicyHandler
|
||||
$this->cspDisabled = true;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->cspDisabled = false;
|
||||
}
|
||||
|
||||
public function updateResponseHeaders(RequestInterface $request, ResponseInterface $response): array
|
||||
{
|
||||
if ($this->cspDisabled) {
|
||||
@@ -168,10 +173,13 @@ final class ContentSecurityPolicyHandler implements ContentSecurityPolicyHandler
|
||||
$directives = [];
|
||||
|
||||
foreach (explode(';', $header ?: '') as $directive) {
|
||||
$parts = explode(' ', trim($directive));
|
||||
if (\count($parts) < 1) { // @phpstan-ignore-line
|
||||
$directive = trim($directive);
|
||||
|
||||
if ('' === $directive) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(' ', $directive);
|
||||
$name = array_shift($parts);
|
||||
$directives[$name] = $parts;
|
||||
}
|
||||
|
||||
@@ -20,4 +20,9 @@ interface ContentSecurityPolicyHandlerInterface
|
||||
* @return array{csp_script_nonce?: ?string, csp_style_nonce?: ?string}
|
||||
*/
|
||||
public function updateResponseHeaders(RequestInterface $request, ResponseInterface $response): array;
|
||||
|
||||
/**
|
||||
* Reset the handler state for long-running processes (Octane, FrankenPHP, etc.).
|
||||
*/
|
||||
public function reset(): void;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,15 @@ final readonly class ResponseExtension implements ResponseExtensionInterface
|
||||
$url = $request->getUri();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,43 @@ namespace Flasher\Prime\Notification;
|
||||
|
||||
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
|
||||
{
|
||||
/**
|
||||
* Set the notification title.
|
||||
*
|
||||
* @param string $title The title to display
|
||||
*/
|
||||
public function title(string $title): static;
|
||||
|
||||
/**
|
||||
* Set the notification message.
|
||||
*
|
||||
* @param string $message The message content
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* @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;
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ trait NotificationStorageMethods
|
||||
return $envelope;
|
||||
}
|
||||
|
||||
private function resolveResourceName(object $object): ?string
|
||||
private function resolveResourceName(object $object): string
|
||||
{
|
||||
$displayName = \is_callable([$object, 'getFlashIdentifier']) ? $object->getFlashIdentifier() : null;
|
||||
|
||||
|
||||
@@ -4,10 +4,42 @@ declare(strict_types=1);
|
||||
|
||||
namespace Flasher\Prime\Notification;
|
||||
|
||||
/**
|
||||
* Notification type constants.
|
||||
*
|
||||
* @phpstan-type NotificationType 'success'|'error'|'info'|'warning'
|
||||
*/
|
||||
final class Type
|
||||
{
|
||||
/** @var 'success' */
|
||||
public const SUCCESS = 'success';
|
||||
|
||||
/** @var 'error' */
|
||||
public const ERROR = 'error';
|
||||
|
||||
/** @var 'info' */
|
||||
public const INFO = 'info';
|
||||
|
||||
/** @var '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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,18 +127,24 @@ final class FlasherPlugin extends Plugin
|
||||
}
|
||||
|
||||
if (!empty($config['scripts'])) {
|
||||
$config['plugins']['flasher']['scripts'] ??= [];
|
||||
$config['plugins']['flasher']['scripts'] += $config['scripts'];
|
||||
$config['plugins']['flasher']['scripts'] = array_merge(
|
||||
$config['plugins']['flasher']['scripts'] ?? [],
|
||||
$config['scripts']
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($config['styles'])) {
|
||||
$config['plugins']['flasher']['styles'] ??= [];
|
||||
$config['plugins']['flasher']['styles'] += $config['styles'];
|
||||
$config['plugins']['flasher']['styles'] = array_merge(
|
||||
$config['plugins']['flasher']['styles'] ?? [],
|
||||
$config['styles']
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($config['options'])) {
|
||||
$config['plugins']['flasher']['options'] ??= [];
|
||||
$config['plugins']['flasher']['options'] += $config['options'];
|
||||
$config['plugins']['flasher']['options'] = array_merge(
|
||||
$config['options'],
|
||||
$config['plugins']['flasher']['options'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($config['plugins'] as $name => $options) {
|
||||
@@ -345,7 +351,7 @@ final class FlasherPlugin extends Plugin
|
||||
return $config;
|
||||
}
|
||||
|
||||
$config['flash_bag'] += array_merge($mapping, $config['flash_bag']);
|
||||
$config['flash_bag'] = array_merge($mapping, $config['flash_bag']);
|
||||
|
||||
return $config;
|
||||
}
|
||||
@@ -391,6 +397,25 @@ final class FlasherPlugin extends Plugin
|
||||
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{
|
||||
* scripts: string[],
|
||||
@@ -400,135 +425,19 @@ final class FlasherPlugin extends Plugin
|
||||
*/
|
||||
private function getDefaultThemes(): array
|
||||
{
|
||||
return [
|
||||
'amazon' => [
|
||||
'scripts' => ['/vendor/flasher/themes/amazon/amazon.min.js'],
|
||||
$themes = [];
|
||||
|
||||
foreach (self::DEFAULT_THEME_NAMES as $name) {
|
||||
$themes[$name] = [
|
||||
'scripts' => ["/vendor/flasher/themes/{$name}/{$name}.min.js"],
|
||||
'styles' => [
|
||||
'/vendor/flasher/flasher.min.css',
|
||||
'/vendor/flasher/themes/amazon/amazon.min.css',
|
||||
"/vendor/flasher/themes/{$name}/{$name}.min.css",
|
||||
],
|
||||
'options' => [],
|
||||
],
|
||||
'amber' => [
|
||||
'scripts' => ['/vendor/flasher/themes/amber/amber.min.js'],
|
||||
'styles' => [
|
||||
'/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' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $themes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@flasher/flasher",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"main": "dist/flasher.cjs.js",
|
||||
|
||||
@@ -68,6 +68,7 @@ final class ResponseManager implements ResponseManagerInterface
|
||||
|
||||
/**
|
||||
* @throws PresenterNotFoundException
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
private function createPresenter(string $alias): PresenterInterface
|
||||
{
|
||||
@@ -77,7 +78,15 @@ final class ResponseManager implements ResponseManagerInterface
|
||||
|
||||
$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();
|
||||
|
||||
if (null === $this->maxDelay) {
|
||||
return $delay >= $this->minDelay;
|
||||
}
|
||||
$meetsMin = null === $this->minDelay || $delay >= $this->minDelay;
|
||||
$meetsMax = null === $this->maxDelay || $delay <= $this->maxDelay;
|
||||
|
||||
if ($delay <= $this->maxDelay) {
|
||||
return $delay >= $this->minDelay;
|
||||
}
|
||||
|
||||
return false;
|
||||
return $meetsMin && $meetsMax;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ final class FilterCriteria implements CriteriaInterface
|
||||
/**
|
||||
* @var \Closure[]
|
||||
*/
|
||||
private array $callbacks;
|
||||
private array $callbacks = [];
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
@@ -36,11 +36,20 @@ final class FilterCriteria implements CriteriaInterface
|
||||
* @param Envelope[] $envelopes
|
||||
*
|
||||
* @return Envelope[]
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function apply(array $envelopes): array
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -11,9 +11,9 @@ final readonly class HopsCriteria implements CriteriaInterface
|
||||
{
|
||||
use RangeExtractor;
|
||||
|
||||
private readonly ?int $minAmount;
|
||||
private ?int $minAmount;
|
||||
|
||||
private readonly ?int $maxAmount;
|
||||
private ?int $maxAmount;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
@@ -44,14 +44,11 @@ final readonly class HopsCriteria implements CriteriaInterface
|
||||
return false;
|
||||
}
|
||||
|
||||
if (null === $this->maxAmount) {
|
||||
return $stamp->getAmount() >= $this->minAmount;
|
||||
}
|
||||
$amount = $stamp->getAmount();
|
||||
|
||||
if ($stamp->getAmount() <= $this->maxAmount) {
|
||||
return $stamp->getAmount() >= $this->minAmount;
|
||||
}
|
||||
$meetsMin = null === $this->minAmount || $amount >= $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();
|
||||
|
||||
if (null === $this->maxPriority) {
|
||||
return $priority >= $this->minPriority;
|
||||
}
|
||||
$meetsMin = null === $this->minPriority || $priority >= $this->minPriority;
|
||||
$meetsMax = null === $this->maxPriority || $priority <= $this->maxPriority;
|
||||
|
||||
if ($priority <= $this->maxPriority) {
|
||||
return $priority >= $this->minPriority;
|
||||
}
|
||||
|
||||
return false;
|
||||
return $meetsMin && $meetsMax;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php-flasher/flasher-laravel": "^2.4.0",
|
||||
"php-flasher/flasher-sweetalert": "^2.4.0"
|
||||
"php-flasher/flasher-laravel": "^2.5.0",
|
||||
"php-flasher/flasher-sweetalert": "^2.5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@flasher/flasher-sweetalert",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"main": "dist/flasher-sweetalert.cjs.js",
|
||||
@@ -11,7 +11,7 @@
|
||||
"ncu": "ncu -u"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@flasher/flasher": "^2.4.0",
|
||||
"@flasher/flasher": "^2.5.0",
|
||||
"sweetalert2": "^11.6.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -162,7 +162,7 @@ final class SweetAlertBuilder extends NotificationBuilder
|
||||
$this->messages($message);
|
||||
}
|
||||
|
||||
if ([] === $options) {
|
||||
if ([] !== $options) {
|
||||
$this->options($options);
|
||||
}
|
||||
|
||||
@@ -229,8 +229,9 @@ final class SweetAlertBuilder extends NotificationBuilder
|
||||
|
||||
public function showClass(string $showClass, string $value): self
|
||||
{
|
||||
/** @var array<string, string> $option */
|
||||
$option = $this->getEnvelope()->getOption('showClass', []);
|
||||
$option[$showClass] = $value; // @phpstan-ignore-line
|
||||
$option[$showClass] = $value;
|
||||
|
||||
$this->option('showClass', $option);
|
||||
|
||||
@@ -239,8 +240,9 @@ final class SweetAlertBuilder extends NotificationBuilder
|
||||
|
||||
public function hideClass(string $hideClass, string $value): self
|
||||
{
|
||||
/** @var array<string, string> $option */
|
||||
$option = $this->getEnvelope()->getOption('hideClass', []);
|
||||
$option[$hideClass] = $value; // @phpstan-ignore-line
|
||||
$option[$hideClass] = $value;
|
||||
|
||||
$this->option('hideClass', $option);
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php-flasher/flasher": "^2.4.0"
|
||||
"php-flasher/flasher": "^2.5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php-flasher/flasher-sweetalert": "^2.4.0",
|
||||
"php-flasher/flasher-symfony": "^2.4.0"
|
||||
"php-flasher/flasher-sweetalert": "^2.5.0",
|
||||
"php-flasher/flasher-symfony": "^2.5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -134,7 +134,7 @@ final class InstallCommand extends Command
|
||||
}
|
||||
|
||||
// Create asset manifest
|
||||
$this->assetManager->createManifest(array_merge([], ...$files));
|
||||
$this->assetManager->createManifest(array_merge(...$files));
|
||||
|
||||
$output->writeln('');
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Flasher\Symfony\EventListener;
|
||||
|
||||
use Flasher\Prime\Http\Csp\ContentSecurityPolicyHandlerInterface;
|
||||
use Flasher\Symfony\Storage\FallbackSession;
|
||||
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
|
||||
* 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
|
||||
{
|
||||
FallbackSession::reset();
|
||||
$this->cspHandler->reset();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ return static function (ContainerConfigurator $container): void {
|
||||
->tag('kernel.reset', ['method' => 'reset'])
|
||||
|
||||
->set('flasher.worker_listener', WorkerListener::class)
|
||||
->args([service('flasher.csp_handler')])
|
||||
->tag('kernel.reset', ['method' => 'reset'])
|
||||
|
||||
->set('flasher.translation_listener', TranslationListener::class)
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php-flasher/flasher": "^2.4.0",
|
||||
"php-flasher/flasher": "^2.5.0",
|
||||
"symfony/config": "^7.0|^8.0",
|
||||
"symfony/console": "^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^7.0|^8.0",
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php-flasher/flasher-laravel": "^2.4.0",
|
||||
"php-flasher/flasher-toastr": "^2.4.0"
|
||||
"php-flasher/flasher-laravel": "^2.5.0",
|
||||
"php-flasher/flasher-toastr": "^2.5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@flasher/flasher-toastr",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"main": "dist/flasher-toastr.cjs.js",
|
||||
@@ -11,7 +11,7 @@
|
||||
"ncu": "ncu -u"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@flasher/flasher": "^2.4.0",
|
||||
"@flasher/flasher": "^2.5.0",
|
||||
"toastr": "^2.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php-flasher/flasher": "^2.4.0"
|
||||
"php-flasher/flasher": "^2.5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php-flasher/flasher-symfony": "^2.4.0",
|
||||
"php-flasher/flasher-toastr": "^2.4.0"
|
||||
"php-flasher/flasher-symfony": "^2.5.0",
|
||||
"php-flasher/flasher-toastr": "^2.5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
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\Notification;
|
||||
use Flasher\Prime\Storage\Filter\Filter;
|
||||
use Flasher\Prime\Storage\Filter\FilterInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class FilterEventTest extends TestCase
|
||||
@@ -141,4 +142,31 @@ final class FilterEventTest extends TestCase
|
||||
$this->assertSame('Second', $retrievedEnvelopes[1]->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
|
||||
);
|
||||
$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();
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
], $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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,4 +88,118 @@ final class RequestExtensionTest extends TestCase
|
||||
|
||||
$this->assertSame($expectedFlatMapping, $flatMappingProperty->getValue($extension), 'Mapping should be flattened correctly.');
|
||||
}
|
||||
|
||||
public function testProcessMultipleMessagesInSingleBag(): void
|
||||
{
|
||||
$messages = ['First message', 'Second message', 'Third message'];
|
||||
|
||||
$this->request->expects()->hasSession()->andReturns(true);
|
||||
$this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type);
|
||||
$this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? $messages : []);
|
||||
|
||||
// Should flash all three messages
|
||||
$this->flasher->expects()->flash('success', 'First message')->once();
|
||||
$this->flasher->expects()->flash('success', 'Second message')->once();
|
||||
$this->flasher->expects()->flash('success', 'Third message')->once();
|
||||
$this->request->expects()->forgetType('happy')->once();
|
||||
|
||||
$extension = new RequestExtension($this->flasher, $this->mapping);
|
||||
$extension->flash($this->request, $this->response);
|
||||
}
|
||||
|
||||
public function testProcessMessagesWithSpecialCharacters(): void
|
||||
{
|
||||
$specialMessage = '<script>alert("XSS")</script> & "quotes" \'single\' < > &';
|
||||
|
||||
$this->request->expects()->hasSession()->andReturns(true);
|
||||
$this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type);
|
||||
$this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? [$specialMessage] : []);
|
||||
|
||||
$this->flasher->expects()->flash('success', $specialMessage)->once();
|
||||
$this->request->expects()->forgetType('happy')->once();
|
||||
|
||||
$extension = new RequestExtension($this->flasher, $this->mapping);
|
||||
$extension->flash($this->request, $this->response);
|
||||
}
|
||||
|
||||
public function testProcessMessagesWithUnicodeContent(): void
|
||||
{
|
||||
$unicodeMessages = [
|
||||
'Hello',
|
||||
'Bonjour',
|
||||
'Hola',
|
||||
];
|
||||
|
||||
$this->request->expects()->hasSession()->andReturns(true);
|
||||
$this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type);
|
||||
$this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? $unicodeMessages : []);
|
||||
|
||||
foreach ($unicodeMessages as $message) {
|
||||
$this->flasher->expects()->flash('success', $message)->once();
|
||||
}
|
||||
$this->request->expects()->forgetType('happy')->once();
|
||||
|
||||
$extension = new RequestExtension($this->flasher, $this->mapping);
|
||||
$extension->flash($this->request, $this->response);
|
||||
}
|
||||
|
||||
public function testProcessEmptyMessageValue(): void
|
||||
{
|
||||
$this->request->expects()->hasSession()->andReturns(true);
|
||||
$this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type);
|
||||
$this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? [''] : []);
|
||||
|
||||
// Empty string message should still be flashed
|
||||
$this->flasher->expects()->flash('success', '')->once();
|
||||
$this->request->expects()->forgetType('happy')->once();
|
||||
|
||||
$extension = new RequestExtension($this->flasher, $this->mapping);
|
||||
$extension->flash($this->request, $this->response);
|
||||
}
|
||||
|
||||
public function testProcessNullMessageValue(): void
|
||||
{
|
||||
$this->request->expects()->hasSession()->andReturns(true);
|
||||
$this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type);
|
||||
$this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? [null] : []);
|
||||
|
||||
// Null values in the messages array should be passed through
|
||||
$this->flasher->expects()->flash('success', null)->once();
|
||||
$this->request->expects()->forgetType('happy')->once();
|
||||
|
||||
$extension = new RequestExtension($this->flasher, $this->mapping);
|
||||
$extension->flash($this->request, $this->response);
|
||||
}
|
||||
|
||||
public function testProcessMixedMessageTypes(): void
|
||||
{
|
||||
// Messages can include strings, nulls, and empty strings
|
||||
$mixedMessages = ['Valid message', '', 'Another valid message'];
|
||||
|
||||
$this->request->expects()->hasSession()->andReturns(true);
|
||||
$this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type);
|
||||
$this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? $mixedMessages : []);
|
||||
|
||||
$this->flasher->expects()->flash('success', 'Valid message')->once();
|
||||
$this->flasher->expects()->flash('success', '')->once();
|
||||
$this->flasher->expects()->flash('success', 'Another valid message')->once();
|
||||
$this->request->expects()->forgetType('happy')->once();
|
||||
|
||||
$extension = new RequestExtension($this->flasher, $this->mapping);
|
||||
$extension->flash($this->request, $this->response);
|
||||
}
|
||||
|
||||
public function testProcessStringMessageAsArray(): void
|
||||
{
|
||||
// getType can return a string which gets cast to array
|
||||
$this->request->expects()->hasSession()->andReturns(true);
|
||||
$this->request->allows('hasType')->andReturnUsing(fn ($type) => 'happy' === $type);
|
||||
$this->request->allows('getType')->andReturnUsing(fn ($type) => 'happy' === $type ? 'Single string message' : []);
|
||||
|
||||
$this->flasher->expects()->flash('success', 'Single string message')->once();
|
||||
$this->request->expects()->forgetType('happy')->once();
|
||||
|
||||
$extension = new RequestExtension($this->flasher, $this->mapping);
|
||||
$extension->flash($this->request, $this->response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,4 +458,375 @@ final class ResponseExtensionTest extends TestCase
|
||||
|
||||
$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 & <script>">Notification</div>';
|
||||
$contentBefore = '<html><body>Content with & 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('& 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: © ™ '.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('©', $content);
|
||||
$this->assertStringContainsString('™', $content);
|
||||
$this->assertStringContainsString(' ', $content);
|
||||
$this->assertStringContainsString('charset="UTF-8"', $content);
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
$responseExtension = new ResponseExtension($flasher, $cspHandler);
|
||||
$responseExtension->render($request, $response);
|
||||
}
|
||||
|
||||
public function testRenderWithEmptyBody(): void
|
||||
{
|
||||
$flasher = \Mockery::mock(FlasherInterface::class);
|
||||
$cspHandler = \Mockery::mock(ContentSecurityPolicyHandlerInterface::class);
|
||||
$request = \Mockery::mock(RequestInterface::class);
|
||||
$response = \Mockery::mock(ResponseInterface::class);
|
||||
|
||||
// Empty body but with placeholder
|
||||
$contentBefore = HtmlPresenter::BODY_END_PLACE_HOLDER;
|
||||
$htmlResponse = '<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -406,4 +406,57 @@ final class FlasherPluginTest extends TestCase
|
||||
$plugin = new FlasherPlugin();
|
||||
$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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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->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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
$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
|
||||
|
||||
@@ -4,13 +4,23 @@ declare(strict_types=1);
|
||||
|
||||
namespace Flasher\Tests\Symfony\EventListener;
|
||||
|
||||
use Flasher\Prime\Http\Csp\ContentSecurityPolicyHandlerInterface;
|
||||
use Flasher\Symfony\EventListener\WorkerListener;
|
||||
use Flasher\Symfony\Storage\FallbackSession;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Contracts\Service\ResetInterface;
|
||||
|
||||
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
|
||||
{
|
||||
FallbackSession::reset();
|
||||
@@ -18,7 +28,7 @@ final class WorkerListenerTest extends TestCase
|
||||
|
||||
public function testListenerImplementsResetInterface(): void
|
||||
{
|
||||
$listener = new WorkerListener();
|
||||
$listener = new WorkerListener($this->cspHandler);
|
||||
|
||||
$this->assertInstanceOf(ResetInterface::class, $listener);
|
||||
}
|
||||
@@ -32,8 +42,11 @@ final class WorkerListenerTest extends TestCase
|
||||
// Verify data is stored
|
||||
$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
|
||||
$listener = new WorkerListener();
|
||||
$listener = new WorkerListener($this->cspHandler);
|
||||
$listener->reset();
|
||||
|
||||
// Verify FallbackSession was reset
|
||||
@@ -42,7 +55,9 @@ final class WorkerListenerTest extends TestCase
|
||||
|
||||
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
|
||||
$listener->reset();
|
||||
@@ -51,4 +66,12 @@ final class WorkerListenerTest extends TestCase
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testResetCallsCspHandlerReset(): void
|
||||
{
|
||||
$this->cspHandler->expects($this->once())->method('reset');
|
||||
|
||||
$listener = new WorkerListener($this->cspHandler);
|
||||
$listener->reset();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user