diff --git a/.ddev/config.yaml b/.ddev/config.yaml new file mode 100644 index 0000000..22361d4 --- /dev/null +++ b/.ddev/config.yaml @@ -0,0 +1,294 @@ +name: pimcore-forms +type: php +docroot: "" +php_version: "8.3" +webserver_type: nginx-fpm +xdebug_enabled: false +additional_hostnames: [] +additional_fqdns: [] +database: + type: mariadb + version: "10.11" +webimage_extra_packages: ['php${DDEV_PHP_VERSION}-pcov'] +use_dns_when_possible: true +composer_version: "2" +web_environment: [] +corepack_enable: false + +# Key features of DDEV's config.yaml: + +# name: # Name of the project, automatically provides +# http://projectname.ddev.site and https://projectname.ddev.site +# If the name is omitted, the project will take the name of the enclosing directory, +# which is useful if you want to have a copy of the project side by side with this one. + +# type: # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress +# See https://docs.ddev.com/en/stable/users/quickstart/ for more +# information on the different project types + +# docroot: # Relative path to the directory containing index.php. + +# php_version: "8.3" # PHP version to use, "5.6" through "8.5" + +# You can explicitly specify the webimage but this +# is not recommended, as the images are often closely tied to DDEV's' behavior, +# so this can break upgrades. + +# webimage: +# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason. +# Typically, this means additions to the existing web image using a .ddev/web-build/Dockerfile.* + +# database: +# type: # mysql, mariadb, postgres +# version: # database version, like "10.11" or "8.0" +# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8 +# MySQL versions can be 5.5-8.0, 8.4 +# PostgreSQL versions can be 9-18 + +# router_http_port: # Port to be used for http (defaults to global configuration, usually 80) +# router_https_port: # Port for https (defaults to global configuration, usually 443) + +# xdebug_enabled: false # Set to true to enable Xdebug and "ddev start" or "ddev restart" +# Note that for most people the commands +# "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better, +# as leaving Xdebug enabled all the time is a big performance hit. + +# xhgui_http_port: "8143" +# xhgui_https_port: "8142" +# The XHGui ports can be changed from the default 8143 and 8142 +# Very rarely used + +# host_xhgui_port: "8142" +# Can be used to change the host binding port of the XHGui +# application. Rarely used; only when port conflict and +# bind_all_ports is used (normally with router disabled) + +# xhprof_mode: [prepend|xhgui|global] +# Set to "xhgui" to enable XHGui features +# "xhgui" will become default in a future major release + +# webserver_type: nginx-fpm, apache-fpm, generic + +# timezone: Europe/Berlin +# If timezone is unset, DDEV will attempt to derive it from the host system timezone +# using the $TZ environment variable or the /etc/localtime symlink. +# This is the timezone used in the containers and by PHP; +# it can be set to any valid timezone, +# see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones +# For example Europe/Dublin or MST7MDT + +# composer_root: +# Relative path to the Composer root directory from the project root. This is +# the directory which contains the composer.json and where all Composer related +# commands are executed. + +# composer_version: "2" +# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1 +# to use the latest major version available at the time your container is built. +# It is also possible to use each other Composer version channel. This includes: +# - 2.2 (latest Composer LTS version) +# - stable +# - preview +# - snapshot +# Alternatively, an explicit Composer version may be specified, for example "2.2.18". +# To reinstall Composer after the image was built, run "ddev debug rebuild". + +# nodejs_version: "22" +# change from the default system Node.js version to any other version. +# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information +# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation, +# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use, +# can specify any version, and is more robust than using 'nvm'. + +# corepack_enable: false +# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm + +# additional_hostnames: +# - somename +# - someothername +# would provide http and https URLs for "somename.ddev.site" +# and "someothername.ddev.site". + +# additional_fqdns: +# - example.com +# - sub1.example.com +# would provide http and https URLs for "example.com" and "sub1.example.com" +# Please take care with this because it can cause great confusion. + +# upload_dirs: "custom/upload/dir" +# +# upload_dirs: +# - custom/upload/dir +# - ../private +# +# would set the destination paths for ddev import-files to /custom/upload/dir +# When Mutagen is enabled this path is bind-mounted so that all the files +# in the upload_dirs don't have to be synced into Mutagen. + +# disable_upload_dirs_warning: false +# If true, turns off the normal warning that says +# "You have Mutagen enabled and your 'php' project type doesn't have upload_dirs set" + +# ddev_version_constraint: "" +# Example: +# ddev_version_constraint: ">= 1.24.8" +# This will enforce that the running ddev version is within this constraint. +# See https://github.com/Masterminds/semver#checking-version-constraints for +# supported constraint formats + +# working_dir: +# web: /var/www/html +# db: /home +# would set the default working directory for the web and db services. +# These values specify the destination directory for ddev ssh and the +# directory in which commands passed into ddev exec are run. + +# omit_containers: [db, ddev-ssh-agent] +# Currently only these containers are supported. Some containers can also be +# omitted globally in the ~/.ddev/global_config.yaml. Note that if you omit +# the "db" container, several standard features of DDEV that access the +# database container will be unusable. In the global configuration it is also +# possible to omit ddev-router, but not here. + +# performance_mode: "global" +# DDEV offers performance optimization strategies to improve the filesystem +# performance depending on your host system. Should be configured globally. +# +# If set, will override the global config. Possible values are: +# - "global": uses the value from the global config. +# - "none": disables performance optimization for this project. +# - "mutagen": enables Mutagen for this project. +# - "nfs": enables NFS for this project. +# +# See https://docs.ddev.com/en/stable/users/install/performance/#nfs +# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen + +# fail_on_hook_fail: False +# Decide whether 'ddev start' should be interrupted by a failing hook + +# host_https_port: "59002" +# The host port binding for https can be explicitly specified. It is +# dynamic unless otherwise specified. +# This is not used by most people, most people use the *router* instead +# of the localhost port. + +# host_webserver_port: "59001" +# The host port binding for the ddev-webserver can be explicitly specified. It is +# dynamic unless otherwise specified. +# This is not used by most people, most people use the *router* instead +# of the localhost port. + +# host_db_port: "59002" +# The host port binding for the ddev-dbserver can be explicitly specified. It is dynamic +# unless explicitly specified. + +# mailpit_http_port: "8025" +# mailpit_https_port: "8026" +# The Mailpit ports can be changed from the default 8025 and 8026 + +# host_mailpit_port: "8025" +# The mailpit port is not normally bound on the host at all, instead being routed +# through ddev-router, but it can be bound directly to localhost if specified here. + +# webimage_extra_packages: [php7.4-tidy, php-bcmath] +# Extra Debian packages that are needed in the webimage can be added here + +# dbimage_extra_packages: [telnet,netcat] +# Extra Debian packages that are needed in the dbimage can be added here + +# use_dns_when_possible: true +# If the host has internet access and the domain configured can +# successfully be looked up, DNS will be used for hostname resolution +# instead of editing /etc/hosts +# Defaults to true + +# project_tld: ddev.site +# The top-level domain used for project URLs +# The default "ddev.site" allows DNS lookup via a wildcard +# If you prefer you can change this to "ddev.local" to preserve +# pre-v1.9 behavior. + +# ngrok_args: --basic-auth username:pass1234 +# Provide extra flags to the "ngrok http" command, see +# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h" + +# disable_settings_management: false +# If true, DDEV will not create CMS-specific settings files like +# Drupal's settings.php/settings.ddev.php or TYPO3's additional.php +# In this case the user must provide all such settings. + +# You can inject environment variables into the web container with: +# web_environment: +# - SOMEENV=somevalue +# - SOMEOTHERENV=someothervalue + +# no_project_mount: false +# (Experimental) If true, DDEV will not mount the project into the web container; +# the user is responsible for mounting it manually or via a script. +# This is to enable experimentation with alternate file mounting strategies. +# For advanced users only! + +# bind_all_interfaces: false +# If true, host ports will be bound on all network interfaces, +# not the localhost interface only. This means that ports +# will be available on the local network if the host firewall +# allows it. + +# default_container_timeout: 120 +# The default time that DDEV waits for all containers to become ready can be increased from +# the default 120. This helps in importing huge databases, for example. + +#web_extra_exposed_ports: +#- name: nodejs +# container_port: 3000 +# http_port: 2999 +# https_port: 3000 +#- name: something +# container_port: 4000 +# https_port: 4000 +# http_port: 3999 +# Allows a set of extra ports to be exposed via ddev-router +# Fill in all three fields even if you don’t intend to use the https_port! +# If you don’t add https_port, then it defaults to 0 and ddev-router will fail to start. +# +# The port behavior on the ddev-webserver must be arranged separately, for example +# using web_extra_daemons. +# For example, with a web app on port 3000 inside the container, this config would +# expose that web app on https://.ddev.site:9999 and http://.ddev.site:9998 +# web_extra_exposed_ports: +# - name: myapp +# container_port: 3000 +# http_port: 9998 +# https_port: 9999 + +#web_extra_daemons: +#- name: "http-1" +# command: "/var/www/html/node_modules/.bin/http-server -p 3000" +# directory: /var/www/html +#- name: "http-2" +# command: "/var/www/html/node_modules/.bin/http-server /var/www/html/sub -p 3000" +# directory: /var/www/html + +# override_config: false +# By default, config.*.yaml files are *merged* into the configuration +# But this means that some things can't be overridden +# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge +# and you can't erase existing hooks or all environment variables. +# However, with "override_config: true" in a particular config.*.yaml file, +# 'use_dns_when_possible: false' can override the existing values, and +# hooks: +# post-start: [] +# or +# web_environment: [] +# or +# additional_hostnames: [] +# can have their intended affect. 'override_config' affects only behavior of the +# config.*.yaml file it exists in. + +# Many DDEV commands can be extended to run tasks before or after the +# DDEV command is executed, for example "post-start", "post-import-db", +# "pre-composer", "post-composer" +# See https://docs.ddev.com/en/stable/users/extend/custom-commands/ for more +# information on the commands that can be extended and the tasks you can define +# for them. Example: +#hooks: diff --git a/.editorconfig b/.editorconfig index 12de41a..1a8b6ca 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,5 +10,8 @@ indent_size = 4 [*.yml] indent_size = 2 +[*.json] +indent_size = 2 + [*.md] indent_size = 2 diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 816cece..c168ab8 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -1,21 +1,19 @@ name: PHP Checks -on: [ push, pull_request ] +on: [push, pull_request] jobs: phpstan_pimcore10: - name: phpstan (PHP ${{ matrix.php }} with Pimcore ${{ matrix.pimcore }} (${{ matrix.stability }}) on ${{ matrix.operating-system }}) runs-on: ${{ matrix.operating-system }} strategy: fail-fast: false matrix: - operating-system: [ ubuntu-latest ] - php: [ "8.1", "8.2", "8.3" ] - pimcore: [ '^11.0' ] - stability: [ prefer-lowest, prefer-stable ] - + operating-system: [ubuntu-latest] + php: ["8.3", "8.4"] + pimcore: ["^12.0"] + stability: [prefer-lowest, prefer-stable] steps: - name: Checkout code @@ -42,14 +40,13 @@ jobs: run: composer run phpstan -- --error-format=github php-cs-fixer: - name: php-cs-fixer runs-on: ${{ matrix.operating-system }} strategy: fail-fast: false matrix: - operating-system: [ ubuntu-latest ] + operating-system: [ubuntu-latest] steps: - uses: actions/checkout@v6 @@ -57,7 +54,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.3 extensions: curl coverage: xdebug @@ -66,3 +63,42 @@ jobs: - name: Ensure code style using php-cs-fixer run: composer run php-cs-fixer-check + + phpunit: + name: phpunit (PHP ${{ matrix.php }} with Pimcore ${{ matrix.pimcore }} (${{ matrix.stability }}) on ${{ matrix.operating-system }}) + runs-on: ${{ matrix.operating-system }} + + strategy: + fail-fast: false + matrix: + operating-system: [ubuntu-latest] + php: ["8.3", "8.4"] + pimcore: ["^12.0"] + stability: [prefer-lowest, prefer-stable] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl + coverage: xdebug + + - name: Install dependencies + run: | + composer require "pimcore/pimcore:${{ matrix.pimcore }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: List installed dependencies + run: composer show -D + + - name: Run PHPUnit tests + run: composer run test + + - name: Check test coverage + run: composer run test-coverage diff --git a/.gitignore b/.gitignore index 9f96be1..dcbcc08 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,8 @@ .php-cs-fixer.cache /composer.lock + +/.phpunit.cache +/coverage +cobertura.xml +report.xml diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index a0fb18a..baaa255 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -1,42 +1,18 @@ notPath('DependencyInjection/Configuration.php') - ->in('src'); +require_once __DIR__ . '/vendor/autoload.php'; -return (new PhpCsFixer\Config()) - ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) - ->setRules([ - '@Symfony' => true, - '@Symfony:risky' => true, - '@PHP80Migration' => true, - '@PHP80Migration:risky' => true, - 'align_multiline_comment' => true, - 'array_indentation' => true, - 'array_syntax' => ['syntax' => 'short'], - 'concat_space' => ['spacing' => 'one'], - 'function_declaration' => ['closure_function_spacing' => 'none'], - 'increment_style' => ['style' => 'post'], - 'method_chaining_indentation' => true, - 'multiline_comment_opening_closing' => true, - 'native_function_invocation' => false, - 'no_null_property_initialization' => true, - 'no_superfluous_phpdoc_tags' => false, - 'no_unset_on_property' => true, - 'no_useless_else' => true, - 'no_useless_return' => true, - 'nullable_type_declaration_for_default_null_value' => true, - 'operator_linebreak' => ['only_booleans' => true], - 'phpdoc_align' => ['align' => 'left'], - 'phpdoc_order' => true, - 'phpdoc_tag_casing' => true, - 'phpdoc_to_comment' => false, - 'regular_callable_call' => true, - 'return_assignment' => true, - 'strict_comparison' => true, - 'strict_param' => true, - 'yoda_style' => false, - ]) - ->setFinder($finder) +use Valantic\PhpCsFixerConfig\ConfigFactory; + +return ConfigFactory::createValanticConfig([ +]) + ->setFinder( + PhpCsFixer\Finder::create() + ->in(__DIR__ . '/src') + ->in(__DIR__ . '/tests') + ) + // Enable risky rules (recommended as the ruleset includes risky rules) ->setRiskyAllowed(true) - ->setUsingCache(true); + // Enable parallel execution + ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) + ; diff --git a/LICENSE.md b/LICENSE.md index 1110e89..d902985 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,595 +1,157 @@ -GNU General Public License -========================== - -_Version 3, 29 June 2007_ -_Copyright © 2007 Free Software Foundation, Inc. <>_ - -Everyone is permitted to copy and distribute verbatim copies of this license -document, but changing it is not allowed. - -## Preamble - -The GNU General Public License is a free, copyleft license for software and other -kinds of works. - -The licenses for most software and other practical works are designed to take away -your freedom to share and change the works. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change all versions of a -program--to make sure it remains free software for all its users. We, the Free -Software Foundation, use the GNU General Public License for most of our software; it -applies also to any other work released this way by its authors. You can apply it to -your programs, too. - -When we speak of free software, we are referring to freedom, not price. Our General -Public Licenses are designed to make sure that you have the freedom to distribute -copies of free software (and charge for them if you wish), that you receive source -code or can get it if you want it, that you can change the software or use pieces of -it in new free programs, and that you know you can do these things. - -To protect your rights, we need to prevent others from denying you these rights or -asking you to surrender the rights. Therefore, you have certain responsibilities if -you distribute copies of the software, or if you modify it: responsibilities to -respect the freedom of others. - -For example, if you distribute copies of such a program, whether gratis or for a fee, -you must pass on to the recipients the same freedoms that you received. You must make -sure that they, too, receive or can get the source code. And you must show them these -terms so they know their rights. - -Developers that use the GNU GPL protect your rights with two steps: **(1)** assert -copyright on the software, and **(2)** offer you this License giving you legal permission -to copy, distribute and/or modify it. - -For the developers' and authors' protection, the GPL clearly explains that there is -no warranty for this free software. For both users' and authors' sake, the GPL -requires that modified versions be marked as changed, so that their problems will not -be attributed erroneously to authors of previous versions. - -Some devices are designed to deny users access to install or run modified versions of -the software inside them, although the manufacturer can do so. This is fundamentally -incompatible with the aim of protecting users' freedom to change the software. The -systematic pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we have designed -this version of the GPL to prohibit the practice for those products. If such problems -arise substantially in other domains, we stand ready to extend this provision to -those domains in future versions of the GPL, as needed to protect the freedom of -users. - -Finally, every program is threatened constantly by software patents. States should -not allow patents to restrict development and use of software on general-purpose -computers, but in those that do, we wish to avoid the special danger that patents -applied to a free program could make it effectively proprietary. To prevent this, the -GPL assures that patents cannot be used to render the program non-free. - -The precise terms and conditions for copying, distribution and modification follow. - -## TERMS AND CONDITIONS - -### 0. Definitions - -“This License” refers to version 3 of the GNU General Public License. - -“Copyright” also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - -“The Program” refers to any copyrightable work licensed under this -License. Each licensee is addressed as “you”. “Licensees” and -“recipients” may be individuals or organizations. - -To “modify” a work means to copy from or adapt all or part of the work in -a fashion requiring copyright permission, other than the making of an exact copy. The -resulting work is called a “modified version” of the earlier work or a -work “based on” the earlier work. - -A “covered work” means either the unmodified Program or a work based on -the Program. - -To “propagate” a work means to do anything with it that, without -permission, would make you directly or secondarily liable for infringement under -applicable copyright law, except executing it on a computer or modifying a private -copy. Propagation includes copying, distribution (with or without modification), -making available to the public, and in some countries other activities as well. - -To “convey” a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through a computer -network, with no transfer of a copy, is not conveying. - -An interactive user interface displays “Appropriate Legal Notices” to the -extent that it includes a convenient and prominently visible feature that **(1)** -displays an appropriate copyright notice, and **(2)** tells the user that there is no -warranty for the work (except to the extent that warranties are provided), that -licensees may convey the work under this License, and how to view a copy of this -License. If the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - -### 1. Source Code - -The “source code” for a work means the preferred form of the work for -making modifications to it. “Object code” means any non-source form of a -work. - -A “Standard Interface” means an interface that either is an official -standard defined by a recognized standards body, or, in the case of interfaces -specified for a particular programming language, one that is widely used among -developers working in that language. - -The “System Libraries” of an executable work include anything, other than -the work as a whole, that **(a)** is included in the normal form of packaging a Major -Component, but which is not part of that Major Component, and **(b)** serves only to -enable use of the work with that Major Component, or to implement a Standard -Interface for which an implementation is available to the public in source code form. -A “Major Component”, in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system (if any) on which -the executable work runs, or a compiler used to produce the work, or an object code -interpreter used to run it. - -The “Corresponding Source” for a work in object code form means all the -source code needed to generate, install, and (for an executable work) run the object -code and to modify the work, including scripts to control those activities. However, -it does not include the work's System Libraries, or general-purpose tools or -generally available free programs which are used unmodified in performing those -activities but which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for the work, and -the source code for shared libraries and dynamically linked subprograms that the work -is specifically designed to require, such as by intimate data communication or -control flow between those subprograms and other parts of the work. - -The Corresponding Source need not include anything that users can regenerate -automatically from other parts of the Corresponding Source. - -The Corresponding Source for a work in source code form is that same work. - -### 2. Basic Permissions - -All rights granted under this License are granted for the term of copyright on the -Program, and are irrevocable provided the stated conditions are met. This License -explicitly affirms your unlimited permission to run the unmodified Program. The -output from running a covered work is covered by this License only if the output, -given its content, constitutes a covered work. This License acknowledges your rights -of fair use or other equivalent, as provided by copyright law. - -You may make, run and propagate covered works that you do not convey, without -conditions so long as your license otherwise remains in force. You may convey covered -works to others for the sole purpose of having them make modifications exclusively -for you, or provide you with facilities for running those works, provided that you -comply with the terms of this License in conveying all material for which you do not -control copyright. Those thus making or running the covered works for you must do so -exclusively on your behalf, under your direction and control, on terms that prohibit -them from making any copies of your copyrighted material outside their relationship -with you. - -Conveying under any other circumstances is permitted solely under the conditions -stated below. Sublicensing is not allowed; section 10 makes it unnecessary. - -### 3. Protecting Users' Legal Rights From Anti-Circumvention Law - -No covered work shall be deemed part of an effective technological measure under any -applicable law fulfilling obligations under article 11 of the WIPO copyright treaty -adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention -of such measures. - -When you convey a covered work, you waive any legal power to forbid circumvention of -technological measures to the extent such circumvention is effected by exercising -rights under this License with respect to the covered work, and you disclaim any -intention to limit operation or modification of the work as a means of enforcing, -against the work's users, your or third parties' legal rights to forbid circumvention -of technological measures. - -### 4. Conveying Verbatim Copies - -You may convey verbatim copies of the Program's source code as you receive it, in any -medium, provided that you conspicuously and appropriately publish on each copy an -appropriate copyright notice; keep intact all notices stating that this License and -any non-permissive terms added in accord with section 7 apply to the code; keep -intact all notices of the absence of any warranty; and give all recipients a copy of -this License along with the Program. - -You may charge any price or no price for each copy that you convey, and you may offer -support or warranty protection for a fee. - -### 5. Conveying Modified Source Versions - -You may convey a work based on the Program, or the modifications to produce it from -the Program, in the form of source code under the terms of section 4, provided that -you also meet all of these conditions: - -* **a)** The work must carry prominent notices stating that you modified it, and giving a -relevant date. -* **b)** The work must carry prominent notices stating that it is released under this -License and any conditions added under section 7. This requirement modifies the -requirement in section 4 to “keep intact all notices”. -* **c)** You must license the entire work, as a whole, under this License to anyone who -comes into possession of a copy. This License will therefore apply, along with any -applicable section 7 additional terms, to the whole of the work, and all its parts, -regardless of how they are packaged. This License gives no permission to license the -work in any other way, but it does not invalidate such permission if you have -separately received it. -* **d)** If the work has interactive user interfaces, each must display Appropriate Legal -Notices; however, if the Program has interactive interfaces that do not display -Appropriate Legal Notices, your work need not make them do so. - -A compilation of a covered work with other separate and independent works, which are -not by their nature extensions of the covered work, and which are not combined with -it such as to form a larger program, in or on a volume of a storage or distribution -medium, is called an “aggregate” if the compilation and its resulting -copyright are not used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work in an aggregate -does not cause this License to apply to the other parts of the aggregate. - -### 6. Conveying Non-Source Forms - -You may convey a covered work in object code form under the terms of sections 4 and -5, provided that you also convey the machine-readable Corresponding Source under the -terms of this License, in one of these ways: - -* **a)** Convey the object code in, or embodied in, a physical product (including a -physical distribution medium), accompanied by the Corresponding Source fixed on a -durable physical medium customarily used for software interchange. -* **b)** Convey the object code in, or embodied in, a physical product (including a -physical distribution medium), accompanied by a written offer, valid for at least -three years and valid for as long as you offer spare parts or customer support for -that product model, to give anyone who possesses the object code either **(1)** a copy of -the Corresponding Source for all the software in the product that is covered by this -License, on a durable physical medium customarily used for software interchange, for -a price no more than your reasonable cost of physically performing this conveying of -source, or **(2)** access to copy the Corresponding Source from a network server at no -charge. -* **c)** Convey individual copies of the object code with a copy of the written offer to -provide the Corresponding Source. This alternative is allowed only occasionally and -noncommercially, and only if you received the object code with such an offer, in -accord with subsection 6b. -* **d)** Convey the object code by offering access from a designated place (gratis or for -a charge), and offer equivalent access to the Corresponding Source in the same way -through the same place at no further charge. You need not require recipients to copy -the Corresponding Source along with the object code. If the place to copy the object -code is a network server, the Corresponding Source may be on a different server -(operated by you or a third party) that supports equivalent copying facilities, -provided you maintain clear directions next to the object code saying where to find -the Corresponding Source. Regardless of what server hosts the Corresponding Source, -you remain obligated to ensure that it is available for as long as needed to satisfy -these requirements. -* **e)** Convey the object code using peer-to-peer transmission, provided you inform -other peers where the object code and Corresponding Source of the work are being -offered to the general public at no charge under subsection 6d. - -A separable portion of the object code, whose source code is excluded from the -Corresponding Source as a System Library, need not be included in conveying the -object code work. - -A “User Product” is either **(1)** a “consumer product”, which -means any tangible personal property which is normally used for personal, family, or -household purposes, or **(2)** anything designed or sold for incorporation into a -dwelling. In determining whether a product is a consumer product, doubtful cases -shall be resolved in favor of coverage. For a particular product received by a -particular user, “normally used” refers to a typical or common use of -that class of product, regardless of the status of the particular user or of the way -in which the particular user actually uses, or expects or is expected to use, the -product. A product is a consumer product regardless of whether the product has -substantial commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - -“Installation Information” for a User Product means any methods, -procedures, authorization keys, or other information required to install and execute -modified versions of a covered work in that User Product from a modified version of -its Corresponding Source. The information must suffice to ensure that the continued -functioning of the modified object code is in no case prevented or interfered with -solely because modification has been made. - -If you convey an object code work under this section in, or with, or specifically for -use in, a User Product, and the conveying occurs as part of a transaction in which -the right of possession and use of the User Product is transferred to the recipient -in perpetuity or for a fixed term (regardless of how the transaction is -characterized), the Corresponding Source conveyed under this section must be -accompanied by the Installation Information. But this requirement does not apply if -neither you nor any third party retains the ability to install modified object code -on the User Product (for example, the work has been installed in ROM). - -The requirement to provide Installation Information does not include a requirement to -continue to provide support service, warranty, or updates for a work that has been -modified or installed by the recipient, or for the User Product in which it has been -modified or installed. Access to a network may be denied when the modification itself -materially and adversely affects the operation of the network or violates the rules -and protocols for communication across the network. - -Corresponding Source conveyed, and Installation Information provided, in accord with -this section must be in a format that is publicly documented (and with an -implementation available to the public in source code form), and must require no -special password or key for unpacking, reading or copying. - -### 7. Additional Terms - -“Additional permissions” are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. Additional -permissions that are applicable to the entire Program shall be treated as though they -were included in this License, to the extent that they are valid under applicable -law. If additional permissions apply only to part of the Program, that part may be -used separately under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - -When you convey a copy of a covered work, you may at your option remove any -additional permissions from that copy, or from any part of it. (Additional -permissions may be written to require their own removal in certain cases when you -modify the work.) You may place additional permissions on material, added by you to a -covered work, for which you have or can give appropriate copyright permission. - -Notwithstanding any other provision of this License, for material you add to a -covered work, you may (if authorized by the copyright holders of that material) -supplement the terms of this License with terms: - -* **a)** Disclaiming warranty or limiting liability differently from the terms of -sections 15 and 16 of this License; or -* **b)** Requiring preservation of specified reasonable legal notices or author -attributions in that material or in the Appropriate Legal Notices displayed by works -containing it; or -* **c)** Prohibiting misrepresentation of the origin of that material, or requiring that -modified versions of such material be marked in reasonable ways as different from the -original version; or -* **d)** Limiting the use for publicity purposes of names of licensors or authors of the -material; or -* **e)** Declining to grant rights under trademark law for use of some trade names, -trademarks, or service marks; or -* **f)** Requiring indemnification of licensors and authors of that material by anyone -who conveys the material (or modified versions of it) with contractual assumptions of -liability to the recipient, for any liability that these contractual assumptions -directly impose on those licensors and authors. - -All other non-permissive additional terms are considered “further -restrictions” within the meaning of section 10. If the Program as you received -it, or any part of it, contains a notice stating that it is governed by this License -along with a term that is a further restriction, you may remove that term. If a -license document contains a further restriction but permits relicensing or conveying -under this License, you may add to a covered work material governed by the terms of -that license document, provided that the further restriction does not survive such -relicensing or conveying. - -If you add terms to a covered work in accord with this section, you must place, in -the relevant source files, a statement of the additional terms that apply to those -files, or a notice indicating where to find the applicable terms. - -Additional terms, permissive or non-permissive, may be stated in the form of a -separately written license, or stated as exceptions; the above requirements apply -either way. - -### 8. Termination - -You may not propagate or modify a covered work except as expressly provided under -this License. Any attempt otherwise to propagate or modify it is void, and will -automatically terminate your rights under this License (including any patent licenses -granted under the third paragraph of section 11). - -However, if you cease all violation of this License, then your license from a -particular copyright holder is reinstated **(a)** provisionally, unless and until the -copyright holder explicitly and finally terminates your license, and **(b)** permanently, -if the copyright holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - -Moreover, your license from a particular copyright holder is reinstated permanently -if the copyright holder notifies you of the violation by some reasonable means, this -is the first time you have received notice of violation of this License (for any -work) from that copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - -Termination of your rights under this section does not terminate the licenses of -parties who have received copies or rights from you under this License. If your -rights have been terminated and not permanently reinstated, you do not qualify to -receive new licenses for the same material under section 10. - -### 9. Acceptance Not Required for Having Copies - -You are not required to accept this License in order to receive or run a copy of the -Program. Ancillary propagation of a covered work occurring solely as a consequence of -using peer-to-peer transmission to receive a copy likewise does not require -acceptance. However, nothing other than this License grants you permission to -propagate or modify any covered work. These actions infringe copyright if you do not -accept this License. Therefore, by modifying or propagating a covered work, you -indicate your acceptance of this License to do so. - -### 10. Automatic Licensing of Downstream Recipients - -Each time you convey a covered work, the recipient automatically receives a license -from the original licensors, to run, modify and propagate that work, subject to this -License. You are not responsible for enforcing compliance by third parties with this -License. - -An “entity transaction” is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an organization, or -merging organizations. If propagation of a covered work results from an entity -transaction, each party to that transaction who receives a copy of the work also -receives whatever licenses to the work the party's predecessor in interest had or -could give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if the predecessor -has it or can get it with reasonable efforts. - -You may not impose any further restrictions on the exercise of the rights granted or -affirmed under this License. For example, you may not impose a license fee, royalty, -or other charge for exercise of rights granted under this License, and you may not -initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging -that any patent claim is infringed by making, using, selling, offering for sale, or -importing the Program or any portion of it. - -### 11. Patents - -A “contributor” is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The work thus -licensed is called the contributor's “contributor version”. - -A contributor's “essential patent claims” are all patent claims owned or -controlled by the contributor, whether already acquired or hereafter acquired, that -would be infringed by some manner, permitted by this License, of making, using, or -selling its contributor version, but do not include claims that would be infringed -only as a consequence of further modification of the contributor version. For -purposes of this definition, “control” includes the right to grant patent -sublicenses in a manner consistent with the requirements of this License. - -Each contributor grants you a non-exclusive, worldwide, royalty-free patent license -under the contributor's essential patent claims, to make, use, sell, offer for sale, -import and otherwise run, modify and propagate the contents of its contributor -version. - -In the following three paragraphs, a “patent license” is any express -agreement or commitment, however denominated, not to enforce a patent (such as an -express permission to practice a patent or covenant not to sue for patent -infringement). To “grant” such a patent license to a party means to make -such an agreement or commitment not to enforce a patent against the party. - -If you convey a covered work, knowingly relying on a patent license, and the -Corresponding Source of the work is not available for anyone to copy, free of charge -and under the terms of this License, through a publicly available network server or -other readily accessible means, then you must either **(1)** cause the Corresponding -Source to be so available, or **(2)** arrange to deprive yourself of the benefit of the -patent license for this particular work, or **(3)** arrange, in a manner consistent with -the requirements of this License, to extend the patent license to downstream -recipients. “Knowingly relying” means you have actual knowledge that, but -for the patent license, your conveying the covered work in a country, or your -recipient's use of the covered work in a country, would infringe one or more -identifiable patents in that country that you have reason to believe are valid. - -If, pursuant to or in connection with a single transaction or arrangement, you -convey, or propagate by procuring conveyance of, a covered work, and grant a patent -license to some of the parties receiving the covered work authorizing them to use, -propagate, modify or convey a specific copy of the covered work, then the patent -license you grant is automatically extended to all recipients of the covered work and -works based on it. - -A patent license is “discriminatory” if it does not include within the -scope of its coverage, prohibits the exercise of, or is conditioned on the -non-exercise of one or more of the rights that are specifically granted under this -License. You may not convey a covered work if you are a party to an arrangement with -a third party that is in the business of distributing software, under which you make -payment to the third party based on the extent of your activity of conveying the -work, and under which the third party grants, to any of the parties who would receive -the covered work from you, a discriminatory patent license **(a)** in connection with -copies of the covered work conveyed by you (or copies made from those copies), or **(b)** -primarily for and in connection with specific products or compilations that contain -the covered work, unless you entered into that arrangement, or that patent license -was granted, prior to 28 March 2007. - -Nothing in this License shall be construed as excluding or limiting any implied -license or other defenses to infringement that may otherwise be available to you -under applicable patent law. - -### 12. No Surrender of Others' Freedom - -If conditions are imposed on you (whether by court order, agreement or otherwise) -that contradict the conditions of this License, they do not excuse you from the -conditions of this License. If you cannot convey a covered work so as to satisfy -simultaneously your obligations under this License and any other pertinent -obligations, then as a consequence you may not convey it at all. For example, if you -agree to terms that obligate you to collect a royalty for further conveying from -those to whom you convey the Program, the only way you could satisfy both those terms -and this License would be to refrain entirely from conveying the Program. - -### 13. Use with the GNU Affero General Public License - -Notwithstanding any other provision of this License, you have permission to link or -combine any covered work with a work licensed under version 3 of the GNU Affero -General Public License into a single combined work, and to convey the resulting work. -The terms of this License will continue to apply to the part which is the covered -work, but the special requirements of the GNU Affero General Public License, section -13, concerning interaction through a network will apply to the combination as such. - -### 14. Revised Versions of this License - -The Free Software Foundation may publish revised and/or new versions of the GNU -General Public License from time to time. Such new versions will be similar in spirit -to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Program specifies that -a certain numbered version of the GNU General Public License “or any later -version” applies to it, you have the option of following the terms and -conditions either of that numbered version or of any later version published by the -Free Software Foundation. If the Program does not specify a version number of the GNU -General Public License, you may choose any version ever published by the Free -Software Foundation. - -If the Program specifies that a proxy can decide which future versions of the GNU -General Public License can be used, that proxy's public statement of acceptance of a -version permanently authorizes you to choose that version for the Program. - -Later license versions may give you additional or different permissions. However, no -additional obligations are imposed on any author or copyright holder as a result of -your choosing to follow a later version. - -### 15. Disclaimer of Warranty - -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. -EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER -EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE -QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE -DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -### 16. Limitation of Liability - -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY -COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS -PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, -INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE -PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE -OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE -WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - -### 17. Interpretation of Sections 15 and 16 - -If the disclaimer of warranty and limitation of liability provided above cannot be -given local legal effect according to their terms, reviewing courts shall apply local -law that most closely approximates an absolute waiver of all civil liability in -connection with the Program, unless a warranty or assumption of liability accompanies -a copy of the Program in return for a fee. - -_END OF TERMS AND CONDITIONS_ - -## How to Apply These Terms to Your New Programs - -If you develop a new program, and you want it to be of the greatest possible use to -the public, the best way to achieve this is to make it free software which everyone -can redistribute and change under these terms. - -To do so, attach the following notices to the program. It is safest to attach them -to the start of each source file to most effectively state the exclusion of warranty; -and each file should have at least the “copyright” line and a pointer to -where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - -If the program does terminal interaction, make it output a short notice like this -when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type 'show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type 'show c' for details. - -The hypothetical commands `show w` and `show c` should show the appropriate parts of -the General Public License. Of course, your program's commands might be different; -for a GUI interface, you would use an “about box”. - -You should also get your employer (if you work as a programmer) or school, if any, to -sign a “copyright disclaimer” for the program, if necessary. For more -information on this, and how to apply and follow the GNU GPL, see -<>. - -The GNU General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may consider it -more useful to permit linking proprietary applications with the library. If this is -what you want to do, use the GNU Lesser General Public License instead of this -License. But first, please read -<>. +# License +Copyright (C) Pimcore GmbH (https://www.pimcore.com) + +This software is available under the terms of the +following Pimcore Open Core License (POCL) + + +**PIMCORE OPEN CORE LICENSE AGREEMENT (POCL)** + +**Last Update: June 2025** + +This Open Core License Agreement ("**Agreement**" or “**POCL**”), effective as of the day of the first installation or use by Customer (the "**Effective Date**"), is by and between Pimcore GmbH, Söllheimer Straße 16, AT-5020 Salzburg, Republic of Austria (hereinafter "**Licensor**" or “**Pimcore**”) and the user of the Software, as defined herein, (hereinafter "**Licensee**" or "**Customer**"). Licensor and Licensee may be referred to herein collectively as the "**Parties**" or individually as a "**Party**." + +**WHEREAS** Licensor desires to license out certain Pimcore Software (“**Software**“). + +**WHEREAS** (a) Software for which the source code is publicly available but which is not licensed out as open source software is "**Open Core Software**" and + (b) Software for which the source code is not publicly available is "**Proprietary Software**", + both covered by this Agreement. + +**WHEREAS** the exact products that are available under this Agreement are defined in the additional contractual documents or by inclusion of, or referral to, this Agreement within the source code or within the source code repositories; if not provided for otherwise, a software element is Proprietary Software. + +**WHEREAS** the Software is protected by copyright world- wide; and + +**WHEREAS** Licensee desires to obtain a license to use the Software for its internal business purposes, subject to the terms and conditions of this Agreement. + +**NOW, THEREFORE**, in consideration of the mutual covenants, terms, and conditions set forth herein, and for other good and valuable consideration, the receipt and sufficiency of which are hereby acknowledged, the Parties agree as follows. + +### 1. LICENSE +1.1 PLEASE READ THIS PIMCORE SOFTWARE LICENSE AGREEMENT CAREFULLY AS IT CONSTITUTES A LEGALLY BINDING AGREEMENT. BY INSTALLING OR USING THE SOFTWARE, YOU ACCEPT AND AGREE TO ALL TERMS AND CONDITIONS OF THIS AGREEMENT, AND CONFIRM THAT YOUR STATEMENT – IF APPLICABLE – ON THE RELEVANT GLOBAL REVENUE IS CORRECT AND COMPLETE. IF YOU REPRESENT A LEGAL ENTITY, YOU REPRESENT AND WARRANT THAT YOU HAVE FULL LEGAL AUTHORITY TO ENTER INTO THIS AGREEMENT TO BIND THAT LEGAL ENTITY. IF YOU DO NOT AGREE TO THESE TERMS AND CONDITIONS, YOU MAY NOT INSTALL OR USE THE SOFTWARE. + +1.2 Pimcore grants the Customer a non-exclusive, non-transferable, non-sublicensable, geographically unlimited right, limited in time to the term of the Agreement, to use the Software and to customize, modify or adapt it for its own purposes. Unless if required by Pimcore for compliance with applicable laws or any order of a governmental authority, the Customer is not obliged to share these modifications, adaptations, and customizations (“**Derivatives**”) with Pimcore or anyone else. + +1.2.1 Solution Development and Production Use (Open Core Software) + +“**Production Use**” means the usage of a software for development of solutions and productions within a business operation. + +a) An organization with total global revenue not exceeding €5 million (€5M) or equivalent amount in other currency annually (“**Threshold**”) may qualify for a free license for Production Use of the Open Core Software, provided such organization is not a part, subsidiary, affiliate, or shell company to another organization, entity, or company group whose total combined revenue exceeds the Threshold. Eligibility must be self-certified by the Customer when starting the use of the Open Core Software and is subject to periodic review and audit by Pimcore. If at any time the Customer’s revenue exceeds the Threshold, a paid commercial license will be required for continued Production Use of the software. The Customer is obliged to inform Pimcore about relevant changes in revenues. Pimcore is entitled to charge license fees retroactively from the date on which Customer exceeded the Threshold. + +b) Non-profit and educational organizations are eligible for a free license for Production Use of the Open Core Software, subject to Pimcore’s non-profit criteria. For this purpose, “non-profit” refers to entities that are legally recognized as non-profit or tax-exempt under applicable law and operate exclusively for charitable, educational, or scientific purposes without profit distribution. Government bodies, municipalities, political parties, and public institutions are excluded unless explicitly approved by Pimcore. + +Pimcore shall decide at its own reasonable discretion whether (a) the Threshold is exceeded or (b) the requirements for non-profit or educational usage are met. Legal recourse is excluded with regard to such decision of Pimcore. + +1.2.2 Non-Production Use and Transition to Production Use (Open Core Software) + +For non-production purposes—such as demonstrations, prototype design, proofs of concept, and sales presentations (collectively referred to as “**Non-Production Use**”)—the use of POCL-based software is free of charge. However, to showcase or demonstrate the features of the Enterprise Edition of Pimcore for any Non-Production Use, a Pimcore Developer License Agreement (PDLA) must be purchased. + +If the Customer or a Partner or any other third person acting on the Customer’s behalf initiates development of a solution with the intention or foreseeable or actual effect of deploying it into production, such use from its beginning shall be deemed Production Use of the Open Core Software for which the Threshold applies from the outset. Individual transition periods to Production Use may be agreed between Pimcore and Customer in writing. + +Pimcore reserves the right to audit, verify and enforce compliance with these terms, including restricting or terminating access to the Open Core Software. + +1.2.3 The use of Proprietary Software is never free of charge. Sect. 1.2.1 and 1.2.2 do not apply to Proprietary Software. + +1.3 Restrictions on Use + +1.3.1 The Customer may not offer the Software as a hosted or managed service by granting third parties access to a significant part of its features or functions. Additionally, the Customer may not fork, modify, or redistribute the Software, or any Derivative, in a manner that results in a competing or functionally comparable product that is offered as a free or commercial alternative to Pimcore’s official offerings. + +1.3.2 The Customer shall also refrain from incorporating the Software, or any Derivative, into a commercial product or service offering materially deriving its economic value from the Software, even if it is not directly exposed or obvious. + +1.3.3 The Customer is also prohibited from representing, implying, or otherwise suggesting that its use, distribution, or customization of the Software is endorsed, certified, or supported by Pimcore, unless such authorization has been explicitly granted in writing. + +1.3.4 The Customer may only use the Software for its own enterprise. The Customer may not use the Software simultaneously in more instances than Customer has acquired usage licences for. The Customer is only permitted to copy the Software to the extent that this is necessary for the intended use, including the correction of errors. The creation of a backup copy is permitted if it is necessary to secure the contractual use. + +1.3.5 The Customer must not, at any time, (i) rent, lease, lend, sell, license, assign, distribute, publish, transfer, or otherwise make available the Software; (ii) reverse engineer, disassemble, decompile, decode, adapt, or otherwise attempt to derive or gain access to source code of the Proprietary Software, in whole or in part; (iii) use the Software in any manner or for any purpose that infringes, misappropriate, or otherwise wireless any intellectual property ride or other ride of any person, or that violates any applicable law. + +1.4 If the Customer violates any of the provisions Sect. 1.2 and 1.3, all rights of usage granted under the POCL shall immediately become invalid and shall automatically revert to Pimcore. In this case, the Customer must immediately and completely cease using the Software, delete all copies of the Software installed on its systems and delete any backup copies made or hand them over to Pimcore. In addition, Pimcore reserves the right to take all legal steps. + +1.5 Sect. 1.4 applies accordingly if a Derivative of the Customer infringe upon patents. + +1.6 The parties may agree on expanded usage rights, arrangements for enterprise customers, and special OEM provisions separately. + +1.7 Upon request, the Customer shall enable Pimcore to verify the proper use of the Software, in particular whether the Customer is using the Software as agreed. For this purpose, the Customer shall provide Pimcore with information, grant access to relevant documents and records and enable an audit of the hardware and software environment by Pimcore or an auditing company named by Pimcore and acceptable to the Customer. Pimcore may carry out the audit on the Customer's premises during the Customer's regular business hours or have it carried out by third parties bound to secrecy. Pimcore shall ensure that the Customer's business operations are disturbed as little as possible by the on-site audit. If the inspection reveals a licence violation by the Customer that is not merely minimal, the Customer shall bear the costs of the inspection, otherwise Pimcore shall bear them. Pimcore reserves all other rights. + +1.8 Licensee acknowledges that, as between Licensee and Licensor, Licensor owns all right, title, and interest, including all intellectual property rights, in and to the Software and, with respect to third-party products, the applicable third-party licensors own all right, title and interest, including all intellectual property rights, in and to the third-party products. + +1.9 Licensor reserves all rights not expressly granted to Licensee in this Agreement. Except for the limited rights and licenses expressly granted under this Agreement, nothing in this Agreement grants, by implication, waiver, estoppel, or otherwise, to Licensee or any third party any intellectual property rights or other right, title, or interest in or to the Software. + +### 2. CONTRIBUTIONS OF DERIVATIVES +2.1 If the Customer wishes to contribute to the Software or to distribute a Derivative, both must be made in accordance with the Pimcore Contributors License Agreement (“PCLA”), available at . The PCLA stipulates the terms under which intellectual contributions are managed, ensuring that all parties' rights are protected. Acceptance of the PCLA is mandatory for all contributors and can be reviewed on the source-code repository. Contributions without adherence to the PCLA will not be accepted. + +2.2 Any contribution to the Software by a Derivative must be clearly documented, in order to maintain transparency and integrity of the source code. + +2.3. Any Derivative distributed must prominently be specified as “Derivative”, comply with the terms of the POCL, include copyright notices, and be licensed as a whole under the terms of the POCL, with the proviso that the recipient (licensee) of the out-licensed Derivative gets the role of the “Customer” regarding rights and obligations. Upon distribution of any Derivative, recipient must be provided with a copy of this POCL. + +### 3. COLLATERAL OBLIGATIONS OF THE CUSTOMER + +3.1 The Customer shall not manipulate, in particular modify, move, remove, suppress, switch off or circumvent licence keys and technical protection mechanisms in the Software, e. g. directly, or through the use of intermediaries, white-labelling, or segmentation of services designed to avoid licensing obligations. + +3.2 The Customer shall not alter or obfuscate any of the Pimcore's licensing, copyright, or other proprietary notices within the Software. Any use of Pimcore’s trademarks must comply with applicable laws. + +3.3 The Customer shall not modify, relocate, disable, or bypass any functionalities associated with the Pimcore Store. + +3.4 The Customer shall not (a) use GPLv3-licensed Pimcore software alongside POCL licensed Software, and shall not (b) revert from POCL to GPLv3, to protect the Customer’s rights in Derivatives. + +3.5 The Customer must ensure that the access data to the user accounts is not passed on to unauthorised third parties and is protected against unauthorised access by third parties. The authorised users shall be instructed accordingly. The Customer shall inform Pimcore immediately if there is a suspicion of misuse of the Software. + +3.6 If Customer infringes upon one of the provisions set up by Sect. 3.1 through 3.5, Sect. 1.4 sentence 1 applies accordingly. + +### 4. CONFIDENTIALITY + +From time to time during the Term, either Party may disclose or make available to the other Party information about its business affairs, products, confidential intellectual property, trade secrets, third-party confidential information, and other sensitive or proprietary information, whether orally or in written, electronic, or other form or media, and whether or not marked, designated or otherwise identified as "confidential" (collectively, "**Confidential Information**"). Confidential Information does not include information that, at the time of disclosure is: (a) in the public domain; (b) known to the receiving Party at the time of disclosure; (c) rightfully obtained by the receiving Party on a non-confidential basis from a third party; or (d) independently developed by the receiving Party. The receiving Party shall not disclose the disclosing Party's Confidential Information to any person or entity, except to the receiving Party's employees who have a need to know the Confidential Information for the receiving Party to exercise its rights or perform its obligations hereunder. Notwithstanding the foregoing, each Party may disclose Confidential Information to the limited extent required (i) in order to comply with the order of a court or other governmental body, or as otherwise necessary to comply with applicable law, provided that the Party making the disclosure pursuant to the order shall first have given written notice to the other Party and made a reasonable effort to obtain a protective order; or (ii) to establish a Party's rights under this Agreement, including to make required court filings. On the expiration or termination of this Agreement, the receiving Party shall promptly return to the disclosing Party all copies, whether in written, electronic, or other form or media, of the disclosing Party's Confidential Information, or destroy all such copies and certify in writing to the disclosing Party that such Confidential Information has been destroyed. Each Party's obligations of non­disclosure with regard to Confidential Information are effective as of the Effective Date and will expire five years from the date first disclosed to the receiving Party; provided, however, with respect to any Confidential Information that constitutes a trade secret (as determined under applicable law), such obligations of non-disclosure will survive the termination or expiration of this Agreement for as long as such Confidential Information remains subject to trade secret protection under applicable law. + +### 5. LIMITED WARRANTY AND WARRANTY DISCLAIMER + +Pimcore warrants that, at the time of delivery, the Software does not contain any virus or other malicious code that would cause the Software to become inoperable or incapable of being used in accordance with its documentation. The warranties set forth herein do not apply and become null and void if Licensee breaches any material provision of this Agreement or any instrument related hereto, or if Licensee, or any person provided access to the Software by Licensee whether or not in violation of this Agreement: (i) installs or uses the Software on or in connection with any hardware or software not specified in the documentation or expressly authorized by Licensor in writing; (ii) illicitly modifies or damages the Software; or (iii) misuses the Software, including any use of the Software other than as specified in the documentation or expressly authorized by Licensor in writing. If any Software fails to comply with the warranty set forth hereinbefore, and such failure is not excluded from warranty pursuant to this provision, Licensor shall, subject to Licensee's promptly notifying Licensor in writing of such failure, at its sole option, either: (i) repair or replace the Software, provided that Licensee provides Licensor with all information Licensor reasonably requests to resolve the reported failure, including sufficient information to enable the Licensor to recreate such failure; or (ii) refund the fees paid for such Software, subject to Licensee's ceasing all use of and, if requested by Licensor, returning to Licensor all copies of the Software. If Licensor repairs or replaces the Software, the warranty will continue to run from the Effective Date and not from Licensee's receipt of the repair or replacement. The remedies set forth in this Section 5 are Licensee's sole remedies and Licensor's sole liability under the limited warranty set forth in this Section 5. + +EXCEPT FOR THE LIMITED WARRANTY SET FORTH IN THIS SECTION 5, THE SOFTWARE AND DOCUMENTATION ARE PROVIDED "AS IS" AND LICENSOR HEREBY DISCLAIMS ALL WARRANTIES, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE. LICENSOR SPECIFICALLY DISCLAIMS ALL IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT, AND ALL WARRANTIES ARISING FROM COURSE OF DEALING, USAGE, OR TRADE PRACTICE. LICENSOR MAKES NO WARRANTY OF ANY KIND THAT THE SOFTWARE AND DOCUMENTATION, OR ANY PRODUCTS OR RESULTS OF THE USE THEREOF, WILL MEET LICENSEE'S OR ANY OTHER PERSON'S REQUIREMENTS, OPERATE WITHOUT INTERRUPTION, ACHIEVE ANY INTENDED RESULT, BE COMPATIBLE OR WORK WITH ANY SOFTWARE, SYSTEM OR OTHER SERVICES, OR BE SECURE, ACCURATE, COMPLETE, FREE OF HARMFUL CODE, OR ERROR FREE. + +### 6. DEFECTS + +6.1 The Customer is obliged to notify Pimcore of any defect or error in the Software immediately after its occurrence. + +6.2 Before reporting any defect or error, the Customer must carry out an analysis of the system environment as far as possible to ensure that the defect or error is not due to system components that are not covered by this Agreement. + +6.3 The Customer shall immediately install or carry out updates or other troubleshooting measures provided by Pimcore. + +6.4 Violations of the obligations to co-operate may result in additional costs for Pimcore. The Customer must reimburse Pimcore for such costs, unless it is not responsible for them. + +### 7. LIMITATION OF LIABILITY + +IN NO EVENT WILL LICENSOR BE LIABLE UNDER OR IN CONNECTION WITH THIS AGREEMENT UNDER ANY LEGAL OR EQUITABLE THEORY, INCLUDING BREACH OF CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, AND OTHERWISE, FOR ANY: (a) CONSEQUENTIAL, INCIDENTAL, INDIRECT, EXEMPLARY, SPECIAL, ENHANCED, OR PUNITIVE DAMAGES; (b) INCREASED COSTS, DIMINUTION IN VALUE OR LOST BUSINESS, PRODUCTION, REVENUES, OR PROFITS; (c) LOSS OF GOODWILL OR REPUTATION; (d) USE, INABILITY TO USE, LOSS, INTERRUPTION, DELAY OR RECOVERY OF ANY DATA, OR BREACH OF DATA OR SYSTEM SECURITY; OR (e) COST OF REPLACEMENT GOODS OR SERVICES, IN EACH CASE REGARDLESS OF WHETHER LICENSOR WAS ADVISED OF THE POSSIBILITY OF SUCH LOSSES OR DAMAGES OR SUCH LOSSES OR DAMAGES WERE OTHERWISE FORESEEABLE. + +IN NO EVENT WILL LICENSOR'S AGGREGATE LIABILITY ARISING OUT OF OR RELATED TO THIS AGREEMENT UNDER ANY LEGAL OR EQUITABLE THEORY, INCLUDING BREACH OF CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, AND OTHERWISE EXCEED THE TOTAL AMOUNTS PAID TO LICENSOR UNDER THIS AGREEMENT IN THE TWELVE (12) MONTH PERIOD PRECEDING THE EVENT GIVING RISE TO THE CLAIM. + +### 8. INDEMNIFICATION + +The Customer shall indemnify Pimcore and its affiliates, officers, directors, employees, agents, and assigns, from and against all claims, losses, damages, liabilities, costs, and expenses (including reasonable attorney’s fees and costs) against Pimcore arising out of or relating to the Customer’s use of the Software or Derivatives. + +### 9. TERMINATION + +Term and termination will be regulated separately. If Customer uses the Software in violation of this Agreement or otherwise violates the use rights or prohibitions contained in this Agreement, Customer’s License shall automatically terminate. Upon termination of this Agreement, the Customer shall uninstall the Software, including all copies, and delete any remaining Software residues from its IT system. The Customer must destroy any backup copies made. At Pimcore's request, the Customer must confirm that it has fulfilled these obligations. + +### 10. REMUNERATION + +The remuneration for the use of the software shall be agreed separately. + +### 11. MISCELLANEOUS + +11.1 The Software may automatically collect and transmit non-personal statistical data related to its installation and use, including but not limited to the number of records in the database, installed modules, system configuration, and usage metrics ("Usage Data"). Such data is collected solely for the purposes of product improvement, support, and analytics. Licensee agrees not to interfere with the collection and transmission of Usage Data. + +11.2 Licensee may not assign or transfer any of its rights or delegate any of its obligations hereunder, in each case whether voluntarily, involuntarily, by operation of law or otherwise, without the prior written consent of Licensor. Any purported assignment, transfer, or delegation in violation of this Section is null and void. No assignment, transfer, or delegation will relieve the assigning or delegating Party of any of its obligations hereunder. This Agreement is binding upon and inures to the benefit of the Parties hereto and their respective permitted successors and assigns. + +11.3 Each Party acknowledges and agrees that a breach or threatened breach by such Party of any of its contractual obligations may cause the other Party irreparable harm for which monetary damages would not be an adequate remedy and agrees that, in the event of such breach or threatened breach, the other Party will be entitled to equitable relief, including a restraining order, an injunction, specific performance, and any other relief that may be available from any court, without any requirement to post a bond or other security, or to prove actual damages or that monetary damages are not an adequate remedy. Such remedies are not exclusive and are in addition to all other remedies that may be available at law, in equity, or otherwise. + +11.4 No amendment to or modification of this Agreement is effective unless it is in writing and signed by an authorized representative of each Party. No waiver by any Party of any of the provisions hereof will be effective unless explicitly set forth in writing and signed by the Party so waiving. Except as otherwise set forth in this Agreement, (i) no failure to exercise, or delay in exercising, any rights, remedy, power, or privilege arising from this Agreement will operate or be construed as a waiver thereof, and (ii) no single or partial exercise of any right, remedy, power, or privilege hereunder will preclude any other or further exercise thereof or the exercise of any other right, remedy, power, or privilege. + +11.5 If any provision of this Agreement is invalid, illegal, or unenforceable in any jurisdiction, such invalidity, illegality, or unenforceability will not affect any other term or provision of this Agreement or invalidate or render unenforceable such term or provision in any other jurisdiction. Upon such determination that any term or other provision is invalid, illegal, or unenforceable, the Parties hereto shall negotiate in good faith to modify this Agreement so as to effect the original intent of the Parties as closely as possible in a mutually acceptable manner in order that the transactions contemplated hereby be consummated as originally contemplated to the greatest extent possible. + +11.6 In all relevant respects that are not regulated by this Agreement, the following documents shall apply, as far as applicable: + +- Pimcore Terms & Conditions, available at [] +- Pimcore Privacy Statement (PPS) +- Pimcore Data Processing Agreement (PDPA) +- Pimcore PaaS Terms & Conditions + +11.7 Specifications originating from the Customer regarding the service content and legal elements, such as GTC or contractual clauses, do not apply. + +11.8 Support, maintenance, and other services remain subject to separate agreements. diff --git a/README.md b/README.md index a2f7900..e047ed6 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,6 @@ public function contactAction(\Valantic\PimcoreFormsBundle\Service\FormService $ ## License -In order to comply with [Pimcore's updated licensing policy](https://pimcore.com/en/resources/blog/breaking-free-pimcore-says-goodbye-to-gpl-and-enters-a-new-era-with-pocl), this bundle is (now) published under the GPLv3 license for compatibility Pimcore Platform Version 2024.4 and will be re-licensed under the POCL license as soon as it is compatible with Pimcore Platform Version 2025.1. +In order to comply with [Pimcore's updated licensing policy](https://pimcore.com/en/resources/blog/breaking-free-pimcore-says-goodbye-to-gpl-and-enters-a-new-era-with-pocl), this bundle is now re-licensed under the POCL license. If you have any questiosn regarding licensing, please reach out to us at [info@cec.valantic.ch](mailto:info@cec.valantic.ch). diff --git a/composer.json b/composer.json index e700d62..274379b 100644 --- a/composer.json +++ b/composer.json @@ -4,38 +4,49 @@ "description": "Forms for Pimcore", "type": "pimcore-bundle", "require": { - "php": "^8.1", + "php": "^8.3", "ext-json": "*", - "limenius/liform": "^0.19", - "pimcore/pimcore": "^11.0", - "ramsey/uuid": "^4.0", - "symfony/form": "^6.0", - "voku/portable-ascii": "^1.5 || ^2.0" + "limenius/liform": "^2.0", + "pimcore/pimcore": "^12.0", + "ramsey/uuid": "^4.9", + "symfony/form": "^6.4 || ^7.3", + "voku/portable-ascii": "^2.0" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", + "doctrine/data-fixtures": "^1.8.2", + "fakerphp/faker": "^1.24.1", + "friendsofphp/php-cs-fixer": "^3.92.5", + "mockery/mockery": "^1.6.12", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^1.12.25", - "phpstan/phpstan-deprecation-rules": "^1.2.1", - "phpstan/phpstan-strict-rules": "^1.6.2", - "rector/rector": "^1.2.10", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-strict-rules": "^2.0.7", + "phpunit/phpunit": "^12.5.5", + "rector/rector": "^2.3.1", "roave/security-advisories": "dev-latest", - "symfony/dependency-injection": "^6.4.20" + "symfony/browser-kit": "^6.4 || ^7.4.3", + "symfony/css-selector": "^6.4 || ^7.4", + "symfony/dependency-injection": "^6.4 || ^7.4.3", + "symfony/phpunit-bridge":"^6.4 || ^7.4.3", + "symfony/test-pack": "^1.2", + "valantic/php-cs-fixer-config": "^2.2.1" }, "autoload": { "psr-4": { "Valantic\\PimcoreFormsBundle\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Valantic\\PimcoreFormsBundle\\Tests\\": "tests/", + "App\\": "tests/Examples/" + } + }, "extra": { "pimcore": { "bundles": [ "Valantic\\PimcoreFormsBundle\\ValanticPimcoreFormsBundle" ] - }, - "bamarni-bin": { - "bin-links": false, - "forward-command": true } }, "license": "MIT", @@ -50,32 +61,33 @@ } ], "scripts": { - "post-install-cmd": [ - "@composer bin all install --ansi" - ], "post-update-cmd": [ - "@composer bump -D", - "@composer bin all update --ansi", - "@composer bin all bump" + "@composer bump -D" ], "phpstan": [ - "vendor/bin/phpstan analyse src --memory-limit=1G" + "vendor/bin/phpstan analyse --memory-limit=1G" ], "php-cs-fixer": [ - "vendor-bin/phpcs/vendor/bin/php-cs-fixer fix --diff" + "./vendor/bin/php-cs-fixer fix --diff" ], "php-cs-fixer-check": [ - "vendor-bin/phpcs/vendor/bin/php-cs-fixer fix --diff --dry-run" + "./vendor/bin/php-cs-fixer fix --diff --dry-run" ], "rector": [ - "./vendor/bin/rector process src" + "./vendor/bin/rector process" + ], + "test": [ + "vendor/bin/phpunit" + ], + "test-coverage": [ + "vendor/bin/phpunit --coverage-html coverage/html --coverage-clover coverage/clover.xml" ] }, "config": { "sort-packages": true, "allow-plugins": { "ocramius/package-versions": true, - "bamarni/composer-bin-plugin": true, + "php-http/discovery": true, "phpstan/extension-installer": true } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 772339d..4cbd867 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,102 +1,104 @@ parameters: ignoreErrors: - - message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" - count: 3 - path: src/DependencyInjection/Configuration.php + message: '#^Casting to string something that''s already string\.$#' + identifier: cast.useless + count: 1 + path: src/Controller/FormController.php - - message: "#^Left side of \\|\\| is always false\\.$#" + message: '#^Left side of \|\| is always false\.$#' + identifier: booleanOr.leftAlwaysFalse count: 1 path: src/DependencyInjection/Configuration.php - - message: "#^Parameter \\#1 \\$callback of function array_map expects \\(callable\\(int\\|string\\)\\: mixed\\)\\|null, Closure\\(string\\)\\: array\\{string, string\\} given\\.$#" + message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(int\|string\)\: mixed\)\|null, Closure\(string\)\: array\{string, string\} given\.$#' + identifier: argument.type count: 1 path: src/Document/Areabrick/Form.php - - message: "#^Method Valantic\\\\PimcoreFormsBundle\\\\Form\\\\Builder\\:\\:field\\(\\) return type has no value type specified in iterable type array\\.$#" + message: '#^Method Valantic\\PimcoreFormsBundle\\Form\\Builder\:\:field\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue count: 1 path: src/Form/Builder.php - - message: "#^Parameter \\#1 \\$callback of function array_map expects \\(callable\\(int\\|string\\)\\: mixed\\)\\|null, Closure\\(string\\)\\: string given\\.$#" + message: '#^Method Valantic\\PimcoreFormsBundle\\Form\\Builder\:\:getType\(\) should return class-string but returns string\.$#' + identifier: return.type count: 1 path: src/Form/Builder.php - - message: "#^Parameter \\#1 \\$callback of function array_map expects \\(callable\\(int\\|string\\)\\: mixed\\)\\|null, Closure\\(string\\)\\: string given\\.$#" + message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(int\|string\)\: mixed\)\|null, Closure\(string\)\: string given\.$#' + identifier: argument.type count: 1 - path: src/Form/Extension/ChoiceTypeExtension.php + path: src/Form/Builder.php - - message: "#^Parameter \\#1 \\$callback of function array_map expects \\(callable\\(int\\|string\\)\\: mixed\\)\\|null, Closure\\(string\\)\\: string given\\.$#" + message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(int\|string\)\: mixed\)\|null, Closure\(string\)\: string given\.$#' + identifier: argument.type count: 1 path: src/Form/Extension/FormAttributeExtension.php - - message: "#^Cannot access offset 'config' on Symfony\\\\Component\\\\Validator\\\\Constraint\\.$#" - count: 1 - path: src/Form/Extension/FormConstraintExtension.php - - - - message: "#^Method Valantic\\\\PimcoreFormsBundle\\\\Form\\\\FormErrorNormalizer\\:\\:normalize\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" + message: '#^Method Valantic\\PimcoreFormsBundle\\Form\\FormErrorNormalizer\:\:normalize\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue count: 1 path: src/Form/FormErrorNormalizer.php - - message: "#^Method Valantic\\\\PimcoreFormsBundle\\\\Form\\\\FormErrorNormalizer\\:\\:normalize\\(\\) return type has no value type specified in iterable type array\\.$#" + message: '#^Method Valantic\\PimcoreFormsBundle\\Form\\FormErrorNormalizer\:\:normalize\(\) return type with generic class ArrayObject does not specify its types\: TKey, TValue$#' + identifier: missingType.generics count: 1 path: src/Form/FormErrorNormalizer.php - - message: "#^Method Valantic\\\\PimcoreFormsBundle\\\\Form\\\\FormErrorNormalizer\\:\\:normalize\\(\\) return type with generic class ArrayObject does not specify its types\\: TKey, TValue$#" - count: 1 - path: src/Form/FormErrorNormalizer.php - - - - message: "#^Method Valantic\\\\PimcoreFormsBundle\\\\Form\\\\Transformer\\\\ButtonTransformer\\:\\:transform\\(\\) return type has no value type specified in iterable type array\\.$#" + message: '#^Method Valantic\\PimcoreFormsBundle\\Form\\Transformer\\ButtonTransformer\:\:transform\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue count: 1 path: src/Form/Transformer/ButtonTransformer.php - - message: "#^Method Valantic\\\\PimcoreFormsBundle\\\\Form\\\\Transformer\\\\ChoiceTransformer\\:\\:transform\\(\\) return type has no value type specified in iterable type array\\.$#" + message: '#^Method Valantic\\PimcoreFormsBundle\\Form\\Transformer\\ChoiceTransformer\:\:transform\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue count: 1 path: src/Form/Transformer/ChoiceTransformer.php - - message: "#^Method Valantic\\\\PimcoreFormsBundle\\\\Form\\\\Transformer\\\\ChoiceTransformer\\:\\:transformMultiple\\(\\) has parameter \\$choices with no type specified\\.$#" + message: '#^Method Valantic\\PimcoreFormsBundle\\Form\\Transformer\\ChoiceTransformer\:\:transformMultiple\(\) has parameter \$choices with no type specified\.$#' + identifier: missingType.parameter count: 1 path: src/Form/Transformer/ChoiceTransformer.php - - message: "#^Method Valantic\\\\PimcoreFormsBundle\\\\Form\\\\Transformer\\\\ChoiceTransformer\\:\\:transformMultiple\\(\\) has parameter \\$titles with no type specified\\.$#" + message: '#^Method Valantic\\PimcoreFormsBundle\\Form\\Transformer\\ChoiceTransformer\:\:transformMultiple\(\) has parameter \$titles with no type specified\.$#' + identifier: missingType.parameter count: 1 path: src/Form/Transformer/ChoiceTransformer.php - - message: "#^Method Valantic\\\\PimcoreFormsBundle\\\\Form\\\\Transformer\\\\ChoiceTransformer\\:\\:transformSingle\\(\\) has parameter \\$choices with no type specified\\.$#" + message: '#^Method Valantic\\PimcoreFormsBundle\\Form\\Transformer\\ChoiceTransformer\:\:transformSingle\(\) has parameter \$choices with no type specified\.$#' + identifier: missingType.parameter count: 1 path: src/Form/Transformer/ChoiceTransformer.php - - message: "#^Method Valantic\\\\PimcoreFormsBundle\\\\Form\\\\Transformer\\\\ChoiceTransformer\\:\\:transformSingle\\(\\) has parameter \\$titles with no type specified\\.$#" + message: '#^Method Valantic\\PimcoreFormsBundle\\Form\\Transformer\\ChoiceTransformer\:\:transformSingle\(\) has parameter \$titles with no type specified\.$#' + identifier: missingType.parameter count: 1 path: src/Form/Transformer/ChoiceTransformer.php - - message: "#^Method Valantic\\\\PimcoreFormsBundle\\\\Form\\\\Transformer\\\\FileTransformer\\:\\:transform\\(\\) return type has no value type specified in iterable type array\\.$#" + message: '#^Method Valantic\\PimcoreFormsBundle\\Form\\Transformer\\FileTransformer\:\:transform\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue count: 1 path: src/Form/Transformer/FileTransformer.php - - message: "#^Return type \\(string\\|null\\) of method Valantic\\\\PimcoreFormsBundle\\\\Model\\\\AbstractMessage\\:\\:key\\(\\) should be covariant with return type \\(string\\) of method Iterator\\\\:\\:key\\(\\)$#" + message: '#^Return type \(string\|null\) of method Valantic\\PimcoreFormsBundle\\Model\\AbstractMessage\:\:key\(\) should be covariant with return type \(string\) of method Iterator\\:\:key\(\)$#' + identifier: method.childReturnType count: 1 path: src/Model/AbstractMessage.php - - - message: "#^Method Valantic\\\\PimcoreFormsBundle\\\\Service\\\\FormService\\:\\:errors\\(\\) should return array but returns array\\|ArrayObject\\|bool\\|float\\|int\\|string\\|null\\.$#" - count: 1 - path: src/Service/FormService.php - diff --git a/phpstan.neon b/phpstan.neon index 34b938a..e644e27 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,8 @@ includes: - phpstan-baseline.neon parameters: + paths: + - src level: 8 @@ -10,7 +12,9 @@ parameters: treatPhpDocTypesAsCertain: false - strictRules: - booleansInConditions: false - disallowedConstructs: false - noVariableVariables: false + ignoreErrors: + - + identifiers: + - ternary.shortNotAllowed + - empty.notAllowed + - property.dynamicName diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..5c2d681 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,41 @@ + + + + + + tests/Unit + + + tests/Integration + + + tests/Functional + + + + + + + + + + + + + + + + + + ./src + + + + + + + + + diff --git a/rector.php b/rector.php index 38e110d..52478e5 100644 --- a/rector.php +++ b/rector.php @@ -2,41 +2,16 @@ declare(strict_types=1); -use Rector\CodeQuality\Rector\Empty_\SimplifyEmptyCheckOnEmptyArrayRector; -use Rector\CodeQuality\Rector\If_\SimplifyIfElseToTernaryRector; -use Rector\CodeQuality\Rector\Isset_\IssetOnPropertyObjectToPropertyExistsRector; -use Rector\CodingStyle\Rector\FuncCall\CountArrayToEmptyArrayComparisonRector; use Rector\Config\RectorConfig; -use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector; -use Rector\Php80\Rector\FunctionLike\MixedTypeRector; -use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector; -use Rector\Strict\Rector\Empty_\DisallowedEmptyRuleFixerRector; return RectorConfig::configure() - ->withPhpSets() - ->withPreparedSets( - codeQuality: true, - ) - ->withAttributesSets( - symfony: true, - doctrine: true, - ) ->withPaths([ __DIR__ . '/src', ]) - ->withSkip([ - SimplifyEmptyCheckOnEmptyArrayRector::class, - DisallowedEmptyRuleFixerRector::class, - NullToStrictStringFuncCallArgRector::class, - StringClassNameToClassConstantRector::class => [ - 'src/DependencyInjection/Compiler/ExtensionCompilerPass.php', - 'src/DependencyInjection/Compiler/TransformerCompilerPass.php', - ], - IssetOnPropertyObjectToPropertyExistsRector::class, - CountArrayToEmptyArrayComparisonRector::class, - SimplifyIfElseToTernaryRector::class => [ - 'src/Form/Transformer/OverwriteAbstractTransformerTrait.php', - ], - MixedTypeRector::class, - ]) - ->withRootFiles(); + ->withPhpSets() + ->withComposerBased( + twig: true, + doctrine: true, + phpunit: true, + symfony: true + ); diff --git a/src/Controller/FormController.php b/src/Controller/FormController.php index c0ea9f8..8be6bef 100644 --- a/src/Controller/FormController.php +++ b/src/Controller/FormController.php @@ -8,7 +8,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerException; use Symfony\Contracts\Translation\TranslatorInterface; use Valantic\PimcoreFormsBundle\Constant\MessageConstants; @@ -30,7 +30,7 @@ public function uiAction(string $name): Response public function htmlAction(string $name, FormService $formService): Response { return $this->render('@ValanticPimcoreForms/html.html.twig', [ - 'form' => $formService->buildForm($name)->createView(), + 'form' => $formService->buildForm($name), ]); } @@ -111,8 +111,8 @@ public function mailDocumentAction(Request $request): array { return array_filter( $request->attributes->all(), - fn ($key): bool => is_string($key) && !str_starts_with($key, '_'), - \ARRAY_FILTER_USE_KEY + static fn ($key): bool => !str_starts_with((string) $key, '_'), + \ARRAY_FILTER_USE_KEY, ); } } diff --git a/src/DependencyInjection/Compiler/ExtensionCompilerPass.php b/src/DependencyInjection/Compiler/ExtensionCompilerPass.php index 9ba7849..72c9f9a 100644 --- a/src/DependencyInjection/Compiler/ExtensionCompilerPass.php +++ b/src/DependencyInjection/Compiler/ExtensionCompilerPass.php @@ -22,15 +22,15 @@ */ class ExtensionCompilerPass implements CompilerPassInterface { - final public const EXTENSION_TAG = 'liform.extension'; + final public const string EXTENSION_TAG = 'liform.extension'; public function process(ContainerBuilder $container): void { - if (!$container->hasDefinition('Limenius\Liform\Liform')) { + if (!$container->hasDefinition(\Limenius\Liform\Liform::class)) { return; } - $liform = $container->getDefinition('Limenius\Liform\Liform'); + $liform = $container->getDefinition(\Limenius\Liform\Liform::class); foreach (array_keys($container->findTaggedServiceIds(self::EXTENSION_TAG)) as $id) { $extension = $container->getDefinition($id); diff --git a/src/DependencyInjection/Compiler/TransformerCompilerPass.php b/src/DependencyInjection/Compiler/TransformerCompilerPass.php index 3853489..f667a70 100644 --- a/src/DependencyInjection/Compiler/TransformerCompilerPass.php +++ b/src/DependencyInjection/Compiler/TransformerCompilerPass.php @@ -23,15 +23,15 @@ */ class TransformerCompilerPass implements CompilerPassInterface { - final public const TRANSFORMER_TAG = 'liform.transformer'; + final public const string TRANSFORMER_TAG = 'liform.transformer'; public function process(ContainerBuilder $container): void { - if (!$container->hasDefinition('Limenius\Liform\Resolver')) { + if (!$container->hasDefinition(\Limenius\Liform\Resolver::class)) { return; } - $resolver = $container->getDefinition('Limenius\Liform\Resolver'); + $resolver = $container->getDefinition(\Limenius\Liform\Resolver::class); foreach ($container->findTaggedServiceIds(self::TRANSFORMER_TAG) as $id => $attributes) { $transformer = $container->getDefinition($id); @@ -47,6 +47,7 @@ public function process(ContainerBuilder $container): void if ($implements === false) { continue; } + if (!isset($implements[TransformerInterface::class])) { throw new \InvalidArgumentException(sprintf("The service %s was tagged as a '%s' but does not implement the mandatory %s", $id, self::TRANSFORMER_TAG, TransformerInterface::class)); } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 37ca692..dbb0898 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -19,80 +19,81 @@ */ class Configuration implements ConfigurationInterface { - final public const SYMFONY_CONSTRAINTS_NAMESPACE = 'Symfony\\Component\\Validator\\Constraints\\'; - final public const SYMFONY_FORMTYPES_NAMESPACE = 'Symfony\\Component\\Form\\Extension\\Core\\Type\\'; + final public const string SYMFONY_CONSTRAINTS_NAMESPACE = 'Symfony\\Component\\Validator\\Constraints\\'; + final public const string SYMFONY_FORMTYPES_NAMESPACE = 'Symfony\\Component\\Form\\Extension\\Core\\Type\\'; public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('valantic_pimcore_forms'); $treeBuilder->getRootNode() ->children() - ->arrayNode('forms') - ->useAttributeAsKey('name') - ->arrayPrototype() - ->children() - ->scalarNode('api_error_message_template') - ->info('Custom error message sprintf() based template. Example like "(%2$s) %1$s". (Params: %1$s = error message, %2$s = localized field label') - ->defaultValue(null) - ->end() - ->booleanNode('csrf') - ->defaultValue(true) - ->info('Whether to enable CSRF protection for this form') - ->end() - ->arrayNode('translate') - ->addDefaultsIfNotSet() - ->children() - ->booleanNode('field_labels') - ->defaultValue(false) - ->info('Whether to pass field labels through the Symfony Translator') - ->end() - ->booleanNode('inline_choices') - ->defaultValue(false) - ->info('Whether to pass choices defined inline (i.e. not using a choice provider) through the Symfony Translator') - ->end() - ->end() - ->end() - ->scalarNode('method') - ->defaultValue('POST') - ->info('HTTP method (POST/GET) to submit this form') - ->validate() - ->ifNotInArray(['GET', 'POST']) - ->thenInvalid('Must be GET or POST') - ->end() - ->end() - ->scalarNode('redirect_handler') - ->defaultNull() - ->info('Service to handle redirecting the form') - ->validate() - ->ifTrue(function(?string $handler): bool { - if ($handler === null) { - return false; - } - - return !in_array(RedirectHandlerInterface::class, class_implements($handler) ?: [], true); - }) - ->thenInvalid('Invalid redirect handler class found. If not null, the service must implement ' . RedirectHandlerInterface::class) - ->end() - ->end() - ->scalarNode('input_handler') - ->defaultNull() - ->info('Service to handle inputs for the form') - ->validate() - ->ifTrue(function(?string $handler): bool { - if ($handler === null) { - return false; - } - - return !in_array(InputHandlerInterface::class, class_implements($handler) ?: [], true); - }) - ->thenInvalid('Invalid input handler class found. If not null, the service must implement ' . InputHandlerInterface::class) - ->end() - ->end() - ->append($this->buildOutputsNode()) - ->append($this->buildFieldsNode()) - ->end() - ->end() - ->end(); + ->arrayNode('forms') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('api_error_message_template') + ->info('Custom error message sprintf() based template. Example like "(%2$s) %1$s". (Params: %1$s = error message, %2$s = localized field label') + ->defaultValue(null) + ->end() + ->booleanNode('csrf') + ->defaultValue(true) + ->info('Whether to enable CSRF protection for this form') + ->end() + ->arrayNode('translate') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('field_labels') + ->defaultValue(false) + ->info('Whether to pass field labels through the Symfony Translator') + ->end() + ->booleanNode('inline_choices') + ->defaultValue(false) + ->info('Whether to pass choices defined inline (i.e. not using a choice provider) through the Symfony Translator') + ->end() + ->end() + ->end() + ->scalarNode('method') + ->defaultValue('POST') + ->info('HTTP method (POST/GET) to submit this form') + ->validate() + ->ifNotInArray(['GET', 'POST']) + ->thenInvalid('Must be GET or POST') + ->end() + ->end() + ->scalarNode('redirect_handler') + ->defaultNull() + ->info('Service to handle redirecting the form') + ->validate() + ->ifTrue(function (?string $handler): bool { + if ($handler === null) { + return false; + } + + return !in_array(RedirectHandlerInterface::class, class_implements($handler) ?: [], true); + }) + ->thenInvalid('Invalid redirect handler class found. If not null, the service must implement ' . RedirectHandlerInterface::class) + ->end() + ->end() + ->scalarNode('input_handler') + ->defaultNull() + ->info('Service to handle inputs for the form') + ->validate() + ->ifTrue(function (?string $handler): bool { + if ($handler === null) { + return false; + } + + return !in_array(InputHandlerInterface::class, class_implements($handler) ?: [], true); + }) + ->thenInvalid('Invalid input handler class found. If not null, the service must implement ' . InputHandlerInterface::class) + ->end() + ->end() + ->append($this->buildOutputsNode()) + ->append($this->buildFieldsNode()) + ->end() + ->end() + ->end() + ; // Here you should define the parameters that are allowed to // configure your bundle. See the documentation linked above for @@ -108,54 +109,60 @@ protected function buildFieldsNode(): ArrayNodeDefinition return $treeBuilder->getRootNode() ->children() ->arrayNode('fields')->isRequired()->requiresAtLeastOneElement()->arrayPrototype() - ->children() - ->scalarNode('type') - ->cannotBeEmpty() - ->info('The type of this FormType') - ->validate() - ->ifTrue(fn(string $type): bool => !class_exists($type) && !class_exists(self::SYMFONY_FORMTYPES_NAMESPACE . $type)) - ->thenInvalid('Invalid type class found. The type should either be a FQN or a subclass of ' . self::SYMFONY_FORMTYPES_NAMESPACE) - ->end() - ->example('TextType') - ->end() - ->variableNode('constraints') - ->defaultValue([]) - ->info('Define the Symfony Constraints for this field') - ->validate() - ->ifTrue(function(array $constraints): bool { - $hasError = false; - foreach ($constraints as $constraint) { - $classExists = fn(string $name): bool => class_exists($name) || class_exists(self::SYMFONY_CONSTRAINTS_NAMESPACE . $name); - if (is_string($constraint)) { - $hasError = $hasError || !$classExists($constraint); - continue; - } - if (is_array($constraint)) { - $hasError = $hasError || !$classExists(array_keys($constraint)[0]); - continue; - } - $hasError = true; - } - - return $hasError; - }) - ->thenInvalid('Invalid constraint class found. The constraint should either be a FQN or a subclass of ' . self::SYMFONY_CONSTRAINTS_NAMESPACE) - ->end() - ->end() - ->variableNode('options') - ->defaultValue([]) - ->info('Any of the valid field options for this FormType') - ->end() - ->scalarNode('provider') - ->defaultValue(null) - ->info('A class to provide the options for this FormType') - ->validate() - ->ifTrue(fn(?string $name): bool => $name === null || !class_exists($name) || !in_array(ChoicesInterface::class, class_implements($name) ?: [], true)) - ->thenInvalid('Provider class must exist and implement ' . ChoicesInterface::class) - ->end() - ->end() - ->end() - ->end(); + ->children() + ->scalarNode('type') + ->cannotBeEmpty() + ->info('The type of this FormType') + ->validate() + ->ifTrue(fn (string $type): bool => !class_exists($type) && !class_exists(self::SYMFONY_FORMTYPES_NAMESPACE . $type)) + ->thenInvalid('Invalid type class found. The type should either be a FQN or a subclass of ' . self::SYMFONY_FORMTYPES_NAMESPACE) + ->end() + ->example('TextType') + ->end() + ->variableNode('constraints') + ->defaultValue([]) + ->info('Define the Symfony Constraints for this field') + ->validate() + ->ifTrue(function (array $constraints): bool { + $hasError = false; + + foreach ($constraints as $constraint) { + $classExists = fn (string $name): bool => class_exists($name) || class_exists(self::SYMFONY_CONSTRAINTS_NAMESPACE . $name); + + if (is_string($constraint)) { + $hasError = $hasError || !$classExists($constraint); + + continue; + } + + if (is_array($constraint)) { + $hasError = $hasError || !$classExists(array_keys($constraint)[0]); + + continue; + } + $hasError = true; + } + + return $hasError; + }) + ->thenInvalid('Invalid constraint class found. The constraint should either be a FQN or a subclass of ' . self::SYMFONY_CONSTRAINTS_NAMESPACE) + ->end() + ->end() + ->variableNode('options') + ->defaultValue([]) + ->info('Any of the valid field options for this FormType') + ->end() + ->scalarNode('provider') + ->defaultValue(null) + ->info('A class to provide the options for this FormType') + ->validate() + ->ifTrue(fn (?string $name): bool => $name === null || !class_exists($name) || !in_array(ChoicesInterface::class, class_implements($name) ?: [], true)) + ->thenInvalid('Provider class must exist and implement ' . ChoicesInterface::class) + ->end() + ->end() + ->end() + ->end() + ; } protected function buildOutputsNode(): ArrayNodeDefinition @@ -165,41 +172,42 @@ protected function buildOutputsNode(): ArrayNodeDefinition return $treeBuilder->getRootNode() ->children() ->arrayNode('outputs') - ->isRequired()->requiresAtLeastOneElement()->arrayPrototype() - ->children() - ->variableNode('type') - ->cannotBeEmpty() - ->info('The type of this output channel, e.g. log, email, http, data_object, asset; or anything implementing ' . OutputInterface::class) - ->end() - ->variableNode('options') - ->defaultValue([]) - ->info('This depends on the output channel') - ->end() - ->end() - ->validate() - ->ifTrue(function($config): bool { - $hasError = false; - - if ($config['type'] === 'http') { - $hasError = $hasError || (filter_var($config['options']['url'] ?? '', \FILTER_VALIDATE_URL) === false); - } - - if ($config['type'] === 'email') { - $hasError = $hasError || (filter_var($config['options']['to'] ?? '', \FILTER_VALIDATE_EMAIL) === false); - } - - if ($config['type'] === 'data_object') { - $hasError = $hasError || !array_key_exists('class', $config['options']) || !array_key_exists('path', $config['options']); - } - - if ($config['type'] === 'asset') { - $hasError = $hasError || !array_key_exists('fields', $config['options']) || !is_array($config['options']['fields']) || !array_key_exists('path', $config['options']); - } - - return $hasError; - }) - ->thenInvalid('There are missing/invalid configuration options') - ->end() - ->end(); + ->isRequired()->requiresAtLeastOneElement()->arrayPrototype() + ->children() + ->variableNode('type') + ->cannotBeEmpty() + ->info('The type of this output channel, e.g. log, email, http, data_object, asset; or anything implementing ' . OutputInterface::class) + ->end() + ->variableNode('options') + ->defaultValue([]) + ->info('This depends on the output channel') + ->end() + ->end() + ->validate() + ->ifTrue(function ($config): bool { + $hasError = false; + + if ($config['type'] === 'http') { + $hasError = $hasError || (filter_var($config['options']['url'] ?? '', \FILTER_VALIDATE_URL) === false); + } + + if ($config['type'] === 'email') { + $hasError = $hasError || (filter_var($config['options']['to'] ?? '', \FILTER_VALIDATE_EMAIL) === false); + } + + if ($config['type'] === 'data_object') { + $hasError = $hasError || !array_key_exists('class', $config['options']) || !array_key_exists('path', $config['options']); + } + + if ($config['type'] === 'asset') { + $hasError = $hasError || !array_key_exists('fields', $config['options']) || !is_array($config['options']['fields']) || !array_key_exists('path', $config['options']); + } + + return $hasError; + }) + ->thenInvalid('There are missing/invalid configuration options') + ->end() + ->end() + ; } } diff --git a/src/DependencyInjection/ValanticPimcoreFormsExtension.php b/src/DependencyInjection/ValanticPimcoreFormsExtension.php index 9343db6..7196f5c 100644 --- a/src/DependencyInjection/ValanticPimcoreFormsExtension.php +++ b/src/DependencyInjection/ValanticPimcoreFormsExtension.php @@ -21,10 +21,10 @@ */ class ValanticPimcoreFormsExtension extends Extension { - final public const TAG_OUTPUT = 'valantic.pimcore_forms.output'; - final public const TAG_REDIRECT_HANDLER = 'valantic.pimcore_forms.redirect_handler'; - final public const TAG_INPUT_HANDLER = 'valantic.pimcore_forms.input_handler'; - final public const TAG_CHOICES = 'valantic.pimcore_forms.choices'; + final public const string TAG_OUTPUT = 'valantic.pimcore_forms.output'; + final public const string TAG_REDIRECT_HANDLER = 'valantic.pimcore_forms.redirect_handler'; + final public const string TAG_INPUT_HANDLER = 'valantic.pimcore_forms.input_handler'; + final public const string TAG_CHOICES = 'valantic.pimcore_forms.choices'; /** * @param array $configs @@ -42,12 +42,10 @@ public function load(array $configs, ContainerBuilder $container): void $container->registerForAutoconfiguration(InputHandlerInterface::class)->addTag(self::TAG_INPUT_HANDLER); $container->registerForAutoconfiguration(ChoicesInterface::class)->addTag(self::TAG_CHOICES); - $ymlLoader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); - $ymlLoader->load('services.yml'); - $ymlLoader->load('transformers.yml'); - - $xmlLoader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); - $xmlLoader->load('liform_services.xml'); - $xmlLoader->load('liform_transformers.xml'); + $yamlLoader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); + $yamlLoader->load('services.yaml'); + $yamlLoader->load('transformers.yaml'); + $yamlLoader->load('liform_services.yaml'); + $yamlLoader->load('liform_transformers.yaml'); } } diff --git a/src/Document/Areabrick/Form.php b/src/Document/Areabrick/Form.php index 1caa7c0..9c1c763 100644 --- a/src/Document/Areabrick/Form.php +++ b/src/Document/Areabrick/Form.php @@ -14,35 +14,41 @@ class Form extends AbstractTemplateAreabrick implements EditableDialogBoxInterface { public function __construct( - protected readonly ConfigurationRepository $configurationRepository + protected readonly ConfigurationRepository $configurationRepository, ) { } + #[\Override] public function getTemplateLocation(): string { return static::TEMPLATE_LOCATION_BUNDLE; } + #[\Override] public function getTemplateSuffix(): string { return static::TEMPLATE_SUFFIX_TWIG; } + #[\Override] public function getHtmlTagOpen(Info $info): string { return ''; } + #[\Override] public function getHtmlTagClose(Info $info): string { return ''; } + #[\Override] public function getName(): string { return 'Form'; } + #[\Override] public function getDescription(): string { return 'Choose a form provided by valantic/pimcore-forms'; @@ -70,7 +76,7 @@ public function getEditableDialogBoxConfiguration(Document\Editable $area, ?Info 'config' => [ 'store' => array_map( fn (string $name): array => [$name, $name], - array_values($names) + array_values($names), ), ], ], diff --git a/src/Document/Twig/Extension/Form.php b/src/Document/Twig/Extension/Form.php index f0be989..f63e6f9 100644 --- a/src/Document/Twig/Extension/Form.php +++ b/src/Document/Twig/Extension/Form.php @@ -12,10 +12,11 @@ class Form extends AbstractExtension { public function __construct( - protected readonly FormService $formService + protected readonly FormService $formService, ) { } + #[\Override] public function getFunctions(): array { return [ @@ -24,7 +25,7 @@ public function getFunctions(): array fn (string $name): FormView => $this->formService->buildForm($name)->createView(), [ 'is_safe' => ['html'], - ] + ], ), new TwigFunction( 'valantic_form_json', diff --git a/src/Form/Builder.php b/src/Form/Builder.php index a9c478d..e3ec34d 100644 --- a/src/Form/Builder.php +++ b/src/Form/Builder.php @@ -10,6 +10,7 @@ use Symfony\Component\Form\Extension\Core\Type\TimeType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\FormTypeInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Validator\Constraints\Choice; use Symfony\Contracts\Translation\TranslatorInterface; @@ -29,15 +30,14 @@ public function __construct( /** * @param array $config - * - * @return FormBuilderInterface */ public function form(string $name, array $config): FormBuilderInterface { $builder = $this->formFactory ->createNamedBuilder($name, FormType::class, null, [ 'csrf_protection' => $config['csrf'], - ]); + ]) + ; $builder->setMethod($config['method']); $builder->setAction($this->urlGenerator->generate('valantic_pimcoreforms_form_api', ['name' => $name])); @@ -49,7 +49,7 @@ public function form(string $name, array $config): FormBuilderInterface * @param array $definition * @param array $formConfig * - * @return array{string,array} + * @return array{class-string,array} */ public function field(string $formName, array $definition, array $formConfig): array { @@ -73,13 +73,18 @@ protected function getConstraintClass(string $name): string return $name; } + /** + * @return class-string + */ protected function getType(string $name): string { if (!str_contains($name, '\\')) { - return sprintf('%s%s', Configuration::SYMFONY_FORMTYPES_NAMESPACE, $name); + $type = sprintf('%s%s', Configuration::SYMFONY_FORMTYPES_NAMESPACE, $name); + } else { + $type = $name; } - return $name; + return $type; } /** @@ -92,17 +97,18 @@ protected function getOptions(string $formName, array $definition, array $formCo { $options = $definition['options']; - if ($formConfig['translate']['field_labels'] && !empty($options['label'])) { + if (!empty($formConfig['translate']['field_labels']) && !empty($options['label'])) { $options['label'] = $this->translator->trans($options['label']); } if (in_array($this->getType($definition['type']), [DateType::class, TimeType::class], true)) { $options['widget'] ??= 'single_text'; } + if ($this->getType($definition['type']) === ChoiceType::class) { if ( empty($definition['provider']) - && $formConfig['translate']['inline_choices'] + && !empty($formConfig['translate']['inline_choices']) ) { // Attribute(s) are matched via label hence both the actual label // and the "attribute label" need to be translated. @@ -114,14 +120,16 @@ protected function getOptions(string $formName, array $definition, array $formCo $options[$key] = array_combine( array_map( fn (string $key): string => $this->translator->trans($key), - array_keys($definition['options'][$key]) + array_keys($definition['options'][$key]), ), - $definition['options'][$key] + $definition['options'][$key], ); } } + if (!empty($definition['provider']) && is_string($definition['provider'])) { $choices = $this->choicesRepository->get($definition['provider']); + if ($choices instanceof ConfigAwareInterface) { $choices->setFormName($formName); $choices->setFieldConfig($formConfig); @@ -146,6 +154,7 @@ protected function getOptions(string $formName, array $definition, array $formCo protected function getConstraints(array $definition, array $options): array { $constraints = []; + foreach ($definition['constraints'] as $constraint) { $className = null; $payload = null; diff --git a/src/Form/Extension/ChoiceTypeExtension.php b/src/Form/Extension/ChoiceTypeExtension.php index 06ad272..bab70e0 100644 --- a/src/Form/Extension/ChoiceTypeExtension.php +++ b/src/Form/Extension/ChoiceTypeExtension.php @@ -12,7 +12,6 @@ class ChoiceTypeExtension implements ExtensionInterface { /** - * @param FormInterface $form * @param array $schema * * @return array @@ -29,12 +28,12 @@ public function apply(FormInterface $form, array $schema): array foreach ($choices as $key => $choice) { $camelCaseKeys = array_map( fn (string $key): string => lcfirst(str_replace('-', '', ucwords($key, '-'))), // https://stackoverflow.com/a/2792045 - array_keys($choice->attr) + array_keys($choice->attr), ); $choice->attr = array_combine( $camelCaseKeys, - array_values($choice->attr) + array_values($choice->attr), ); $choices[$key] = $choice; } diff --git a/src/Form/Extension/FormAttributeExtension.php b/src/Form/Extension/FormAttributeExtension.php index 9c9cdae..627483d 100644 --- a/src/Form/Extension/FormAttributeExtension.php +++ b/src/Form/Extension/FormAttributeExtension.php @@ -10,7 +10,6 @@ class FormAttributeExtension implements ExtensionInterface { /** - * @param FormInterface $form * @param array $schema * * @return array @@ -23,12 +22,12 @@ public function apply(FormInterface $form, array $schema): array $camelCaseKeys = array_map( fn (string $key): string => lcfirst(str_replace('-', '', ucwords($key, '-'))), // https://stackoverflow.com/a/2792045 - array_keys($schema['attr']) + array_keys($schema['attr']), ); $schema['attr'] = array_combine( $camelCaseKeys, - array_values($schema['attr']) + array_values($schema['attr']), ); return $schema; diff --git a/src/Form/Extension/FormConstraintExtension.php b/src/Form/Extension/FormConstraintExtension.php index 05165cd..0b24f91 100644 --- a/src/Form/Extension/FormConstraintExtension.php +++ b/src/Form/Extension/FormConstraintExtension.php @@ -6,14 +6,12 @@ use Limenius\Liform\Transformer\ExtensionInterface; use Symfony\Component\Form\FormInterface; -use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Regex; use Valantic\PimcoreFormsBundle\DependencyInjection\Configuration; class FormConstraintExtension implements ExtensionInterface { /** - * @param FormInterface $form * @param array $schema * * @return array @@ -29,7 +27,6 @@ public function apply(FormInterface $form, array $schema): array $schema['constraints'] = []; foreach ($constraints as $constraint) { - /** @var Constraint $data */ $data = [ 'type' => str_replace(Configuration::SYMFONY_CONSTRAINTS_NAMESPACE, '', $constraint::class), 'config' => json_decode(json_encode($constraint, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR), diff --git a/src/Form/Extension/FormDataExtension.php b/src/Form/Extension/FormDataExtension.php index ad6e4d5..37ec0d2 100644 --- a/src/Form/Extension/FormDataExtension.php +++ b/src/Form/Extension/FormDataExtension.php @@ -10,7 +10,6 @@ class FormDataExtension implements ExtensionInterface { /** - * @param FormInterface $form * @param array $schema * * @return array diff --git a/src/Form/Extension/FormNameExtension.php b/src/Form/Extension/FormNameExtension.php index b82af13..dab47bf 100644 --- a/src/Form/Extension/FormNameExtension.php +++ b/src/Form/Extension/FormNameExtension.php @@ -11,7 +11,6 @@ class FormNameExtension implements ExtensionInterface { /** - * @param FormInterface $form * @param array $schema * * @return array diff --git a/src/Form/Extension/FormTypeExtension.php b/src/Form/Extension/FormTypeExtension.php index 7aefb0c..2bc9b9a 100644 --- a/src/Form/Extension/FormTypeExtension.php +++ b/src/Form/Extension/FormTypeExtension.php @@ -45,7 +45,6 @@ class FormTypeExtension implements ExtensionInterface { /** - * @param FormInterface $form * @param array $schema * * @return array @@ -62,18 +61,21 @@ public function apply(FormInterface $form, array $schema): array if ($type === ChoiceType::class) { // https://symfony.com/doc/current/reference/forms/types/choice.html#select-tag-checkboxes-or-radio-buttons - $expanded = $form->getConfig()->getOption('expanded'); - $multiple = $form->getConfig()->getOption('multiple'); + $expanded = $form->getConfig()->getOption('expanded') === true; + $multiple = $form->getConfig()->getOption('multiple') === true; if (!$expanded && !$multiple) { $formType = 'select.single'; } + if (!$expanded && $multiple) { $formType = 'select.multiple'; } + if ($expanded && !$multiple) { $formType = 'radio'; } + if ($expanded && $multiple) { $formType = 'checkboxes'; } diff --git a/src/Form/Extension/HiddenTypeExtension.php b/src/Form/Extension/HiddenTypeExtension.php index 7212df8..83f1edc 100644 --- a/src/Form/Extension/HiddenTypeExtension.php +++ b/src/Form/Extension/HiddenTypeExtension.php @@ -11,7 +11,6 @@ class HiddenTypeExtension implements ExtensionInterface { /** - * @param FormInterface $form * @param array $schema * * @return array diff --git a/src/Form/FormErrorNormalizer.php b/src/Form/FormErrorNormalizer.php index ebadea9..c9cfd34 100644 --- a/src/Form/FormErrorNormalizer.php +++ b/src/Form/FormErrorNormalizer.php @@ -4,6 +4,7 @@ namespace Valantic\PimcoreFormsBundle\Form; +use Symfony\Component\Form\Form; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormErrorIterator; use Symfony\Component\Form\FormInterface; @@ -16,20 +17,25 @@ class FormErrorNormalizer implements NormalizerInterface { public function __construct( protected readonly TranslatorInterface $translator, - protected readonly ConfigurationRepository $configurationRepository + protected readonly ConfigurationRepository $configurationRepository, ) { } - public function normalize($object, ?string $format = null, array $context = []) + public function normalize($object, ?string $format = null, array $context = []): float|int|bool|\ArrayObject|array|string|null { return $this->convertFormToArray($object); } - public function supportsNormalization($data, ?string $format = null): bool + public function supportsNormalization($data, ?string $format = null, array $context = []): bool { return $data instanceof FormInterface && $data->isSubmitted() && !$data->isValid(); } + public function getSupportedTypes(?string $format): array + { + return [Form::class => true]; + } + /** * @return array> * @@ -68,8 +74,6 @@ protected function convertFormToArray(FormInterface $data): array } /** - * @param string|null $customErrorMessageTemplate - * * @return array */ protected function buildErrorEntry(FormError $error, ?string $customErrorMessageTemplate = null): array @@ -100,13 +104,11 @@ protected function buildErrorEntry(FormError $error, ?string $customErrorMessage } /** - * @return string|null - * * @see https://github.com/schmittjoh/serializer/blob/master/src/Handler/FormErrorHandler.php */ protected function getErrorMessage(FormError $error): ?string { - if (null !== $error->getMessagePluralization()) { + if ($error->getMessagePluralization() !== null) { return $this->translator->trans($error->getMessageTemplate(), ['%count%' => $error->getMessagePluralization()] + $error->getMessageParameters(), 'validators'); } diff --git a/src/Form/Output/AssetOutput.php b/src/Form/Output/AssetOutput.php index 38cd050..f90c4ce 100644 --- a/src/Form/Output/AssetOutput.php +++ b/src/Form/Output/AssetOutput.php @@ -83,7 +83,7 @@ protected function getSubfolderName(): string { $base = sprintf('%s_%s', $this->form->getName(), date('Ymd-His')); - if ($this->config['createHashedFolder'] ?? true) { + if (($this->config['createHashedFolder'] ?? true) === true) { return $base . sprintf('_%s', Uuid::uuid4()->toString()); } @@ -93,7 +93,7 @@ protected function getSubfolderName(): string protected function getFilename(UploadedFile $file): string { $fileName = ASCII::to_filename( - pathinfo($file->getClientOriginalName(), \PATHINFO_FILENAME) . '.' . $file->guessExtension() + pathinfo($file->getClientOriginalName(), \PATHINFO_FILENAME) . '.' . $file->guessExtension(), ); return Asset\Service::getValidKey($fileName, 'asset'); diff --git a/src/Form/Output/EmailOutput.php b/src/Form/Output/EmailOutput.php index 9204953..7021560 100644 --- a/src/Form/Output/EmailOutput.php +++ b/src/Form/Output/EmailOutput.php @@ -46,9 +46,6 @@ protected function getTo(): string return $this->config['to']; } - /** - * @return Document|int|string - */ protected function getDocument(): int|Document|string { return $this->config['document']; diff --git a/src/Form/Output/HttpOutput.php b/src/Form/Output/HttpOutput.php index c8fd0aa..be1c931 100644 --- a/src/Form/Output/HttpOutput.php +++ b/src/Form/Output/HttpOutput.php @@ -16,6 +16,7 @@ public static function name(): string public function handle(OutputResponse $outputResponse): OutputResponse { $ch = curl_init($this->config['url']); + if ($ch === false) { throw new \RuntimeException(sprintf('Failed to initialize curl for %s', $this->config['url'])); } diff --git a/src/Form/Output/OutputInterface.php b/src/Form/Output/OutputInterface.php index 039efb8..8d00b40 100644 --- a/src/Form/Output/OutputInterface.php +++ b/src/Form/Output/OutputInterface.php @@ -9,21 +9,17 @@ interface OutputInterface { + public static function name(): string; + /** - * @param string $key - * @param FormInterface $form * @param array $config */ public function initialize(string $key, FormInterface $form, array $config): void; /** * @param OutputInterface[] $handlers - * - * @return void */ public function setOutputHandlers(array $handlers): void; public function handle(OutputResponse $outputResponse): OutputResponse; - - public static function name(): string; } diff --git a/src/Form/Transformer/ButtonTransformer.php b/src/Form/Transformer/ButtonTransformer.php index 947676e..1758999 100644 --- a/src/Form/Transformer/ButtonTransformer.php +++ b/src/Form/Transformer/ButtonTransformer.php @@ -11,6 +11,7 @@ class ButtonTransformer extends AbstractTransformer { use OverwriteAbstractTransformerTrait; + #[\Override] public function isRequired(FormInterface $form): bool { return false; diff --git a/src/Form/Transformer/ChoiceTransformer.php b/src/Form/Transformer/ChoiceTransformer.php index 57d37a8..f937cf3 100644 --- a/src/Form/Transformer/ChoiceTransformer.php +++ b/src/Form/Transformer/ChoiceTransformer.php @@ -5,6 +5,7 @@ namespace Valantic\PimcoreFormsBundle\Form\Transformer; use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView; +use Symfony\Component\Form\ChoiceList\View\ChoiceView; use Symfony\Component\Form\FormInterface; /** @@ -17,19 +18,23 @@ class ChoiceTransformer extends \Limenius\Liform\Transformer\ChoiceTransformer { use OverwriteAbstractTransformerTrait; + #[\Override] public function transform(FormInterface $form, array $extensions = [], $widget = null): array { $formView = $form->createView(); $choices = []; $titles = []; + foreach ($formView->vars['choices'] as $choiceView) { if ($choiceView instanceof ChoiceGroupView) { foreach ($choiceView->choices as $choiceItem) { - $choices[] = $choiceItem->value; - $titles[] = $choiceItem->label; + if ($choiceItem instanceof ChoiceView) { + $choices[] = $choiceItem->value; + $titles[] = $choiceItem->label; + } } - } else { + } elseif ($choiceView instanceof ChoiceView) { $choices[] = $choiceView->value; $titles[] = $choiceView->label; } @@ -83,7 +88,7 @@ private function transformMultiple(FormInterface $form, $choices, $titles): arra 'enum_titles' => $titles, ], ], - 'minItems' => $this->isRequired($form) ? 1 : 0, + 'minItems' => $this->isRequired($form) === true ? 1 : 0, 'uniqueItems' => true, 'type' => 'array', ]; diff --git a/src/Form/Transformer/FileTransformer.php b/src/Form/Transformer/FileTransformer.php index 2bd007a..950443f 100644 --- a/src/Form/Transformer/FileTransformer.php +++ b/src/Form/Transformer/FileTransformer.php @@ -8,6 +8,7 @@ class FileTransformer extends StringTransformer { + #[\Override] public function transform(FormInterface $form, array $extensions = [], $widget = null): array { $schema = ['type' => 'file']; diff --git a/src/Form/Transformer/OverwriteAbstractTransformerTrait.php b/src/Form/Transformer/OverwriteAbstractTransformerTrait.php index ce885c8..35c218b 100644 --- a/src/Form/Transformer/OverwriteAbstractTransformerTrait.php +++ b/src/Form/Transformer/OverwriteAbstractTransformerTrait.php @@ -38,7 +38,8 @@ protected function addLabel(FormInterface $form, array $schema): array protected function addAttr(FormInterface $form, array $schema): array { $attr = $form->getConfig()->getOption('attr'); - if ($attr) { + + if (!empty($attr)) { $schema['attr'] = $attr; } diff --git a/src/Form/Type/ChoicesInterface.php b/src/Form/Type/ChoicesInterface.php index 619ec8b..bf87af2 100644 --- a/src/Form/Type/ChoicesInterface.php +++ b/src/Form/Type/ChoicesInterface.php @@ -11,20 +11,9 @@ interface ChoicesInterface */ public function choices(): array; - /** - * @param mixed $choice - * @param mixed $key - * @param mixed $value - * - * @return string|null - */ public function choiceLabel(mixed $choice, mixed $key, mixed $value): ?string; /** - * @param mixed $choice - * @param mixed $key - * @param mixed $value - * * @return array */ public function choiceAttribute(mixed $choice, mixed $key, mixed $value): array; diff --git a/src/Form/Type/SubheaderType.php b/src/Form/Type/SubheaderType.php index 71ec404..626386c 100644 --- a/src/Form/Type/SubheaderType.php +++ b/src/Form/Type/SubheaderType.php @@ -17,6 +17,7 @@ public function configureOptions(OptionsResolver $resolver): void ]); } + #[\Override] public function getParent(): string { return HiddenType::class; diff --git a/src/Http/ApiResponse.php b/src/Http/ApiResponse.php index 81420e9..2c67a98 100644 --- a/src/Http/ApiResponse.php +++ b/src/Http/ApiResponse.php @@ -11,10 +11,7 @@ class ApiResponse extends JsonResponse /** * @param string|array|null $data * @param array|array> $messages - * @param int $status - * @param string|null $redirectUrl * @param array $headers - * @param bool $isJson */ public function __construct( $data = null, @@ -22,7 +19,7 @@ public function __construct( int $status = self::HTTP_OK, ?string $redirectUrl = null, array $headers = [], - bool $isJson = false + bool $isJson = false, ) { // messages needs to be an array of messages // for convenience, a single message can be passed diff --git a/src/Model/AbstractMessage.php b/src/Model/AbstractMessage.php index e1138a4..da36718 100644 --- a/src/Model/AbstractMessage.php +++ b/src/Model/AbstractMessage.php @@ -4,10 +4,8 @@ namespace Valantic\PimcoreFormsBundle\Model; -use Iterator; - /** - * @implements Iterator + * @implements \Iterator */ abstract class AbstractMessage implements \JsonSerializable, \Iterator, \Stringable { @@ -18,6 +16,16 @@ public function __toString(): string return (string) json_encode($this->jsonSerialize(), \JSON_THROW_ON_ERROR); } + /** + * @return array + */ + abstract protected function requiredAttributes(): array; + + /** + * @return array + */ + abstract protected function optionalAttributes(): array; + /** * @return array */ @@ -87,14 +95,4 @@ protected function validKeys(): array { return array_keys($this->arraySerialize()); } - - /** - * @return array - */ - abstract protected function requiredAttributes(): array; - - /** - * @return array - */ - abstract protected function optionalAttributes(): array; } diff --git a/src/Model/OutputResponse.php b/src/Model/OutputResponse.php index 48a4a23..bcbd17f 100644 --- a/src/Model/OutputResponse.php +++ b/src/Model/OutputResponse.php @@ -46,15 +46,12 @@ public function addMessage(AbstractMessage $message): self return $this; } - /** - * @return bool - */ public function getOverallStatus(): bool { return array_reduce( $this->statuses, fn ($previous, $current) => $previous && $current, - true + true, ); } diff --git a/src/Repository/ConfigurationRepository.php b/src/Repository/ConfigurationRepository.php index ce75f6c..d0815a9 100644 --- a/src/Repository/ConfigurationRepository.php +++ b/src/Repository/ConfigurationRepository.php @@ -8,10 +8,10 @@ class ConfigurationRepository { - final public const CONTAINER_TAG = 'valantic.pimcore_forms.config'; + final public const string CONTAINER_TAG = 'valantic.pimcore_forms.config'; public function __construct( - protected readonly ParameterBagInterface $parameterBag + protected readonly ParameterBagInterface $parameterBag, ) { } diff --git a/src/Repository/OutputRepository.php b/src/Repository/OutputRepository.php index 45ca787..d468f57 100644 --- a/src/Repository/OutputRepository.php +++ b/src/Repository/OutputRepository.php @@ -4,7 +4,6 @@ namespace Valantic\PimcoreFormsBundle\Repository; -use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; use Valantic\PimcoreFormsBundle\DependencyInjection\ValanticPimcoreFormsExtension; use Valantic\PimcoreFormsBundle\Exception\DuplicateOutputException; use Valantic\PimcoreFormsBundle\Exception\UnknownOutputException; @@ -21,8 +20,8 @@ class OutputRepository * @param iterable $outputs */ public function __construct( - #[TaggedIterator(ValanticPimcoreFormsExtension::TAG_OUTPUT)] - iterable $outputs + #[\Symfony\Component\DependencyInjection\Attribute\AutowireIterator(ValanticPimcoreFormsExtension::TAG_OUTPUT)] + iterable $outputs, ) { $this->outputs = $this->iterableToArray($outputs); } diff --git a/src/Resources/config/liform_services.xml b/src/Resources/config/liform_services.xml deleted file mode 100644 index 9c8164c..0000000 --- a/src/Resources/config/liform_services.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Resources/config/liform_services.yaml b/src/Resources/config/liform_services.yaml new file mode 100644 index 0000000..e55e4ce --- /dev/null +++ b/src/Resources/config/liform_services.yaml @@ -0,0 +1,52 @@ +services: + Limenius\Liform\Form\Extension\AddLiformExtension: + public: true + tags: + - { name: form.type_extension, extended-type: Symfony\Component\Form\Extension\Core\Type\FormType } + + liform.add_schema_extension: + alias: Limenius\Liform\Form\Extension\AddLiformExtension + public: true + + # Normalizes FormInterface when using the symfony serializer + Limenius\Liform\Serializer\Normalizer\FormErrorNormalizer: + public: true + arguments: + - '@translator' + tags: + - { name: serializer.normalizer, priority: -10 } + + liform.serializer.form_error_normalizer: + alias: Limenius\Liform\Serializer\Normalizer\FormErrorNormalizer + public: true + + Limenius\Liform\Serializer\Normalizer\InitialValuesNormalizer: + public: true + tags: + - { name: serializer.normalizer, priority: -10 } + + liform.serializer.initial_values_normalizer: + alias: Limenius\Liform\Serializer\Normalizer\InitialValuesNormalizer + public: false + + Limenius\Liform\Resolver: + public: true + + liform.resolver: + alias: Limenius\Liform\Resolver + public: true + + Limenius\Liform\Liform: + public: true + arguments: + - '@Limenius\Liform\Resolver' + + liform: + alias: Limenius\Liform\Liform + public: true + + liform.guesser.validator: + class: Limenius\Liform\Guesser\ValidatorGuesser + public: true + arguments: + - '@validator.mapping.class_metadata_factory' diff --git a/src/Resources/config/liform_transformers.xml b/src/Resources/config/liform_transformers.xml deleted file mode 100644 index 77b1922..0000000 --- a/src/Resources/config/liform_transformers.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Resources/config/liform_transformers.yaml b/src/Resources/config/liform_transformers.yaml new file mode 100644 index 0000000..ea81440 --- /dev/null +++ b/src/Resources/config/liform_transformers.yaml @@ -0,0 +1,62 @@ +services: + Limenius\Liform\Transformer\AbstractTransformer: + abstract: true + arguments: + - '@translator' + - '@liform.guesser.validator' + + liform.transformer.integer: + class: Valantic\PimcoreFormsBundle\Form\Transformer\IntegerTransformer + parent: Limenius\Liform\Transformer\AbstractTransformer + tags: + - { name: liform.transformer, form_type: integer } + + liform.transformer.array: + class: Valantic\PimcoreFormsBundle\Form\Transformer\ArrayTransformer + arguments: + - '@translator' + - '@Limenius\Liform\Resolver' + - '@liform.guesser.validator' + tags: + - { name: liform.transformer, form_type: collection } + + liform.transformer.compound: + class: Valantic\PimcoreFormsBundle\Form\Transformer\CompoundTransformer + arguments: + - '@translator' + - '@Limenius\Liform\Resolver' + - '@liform.guesser.validator' + tags: + - { name: liform.transformer, form_type: compound } + + liform.transformer.choice: + class: Valantic\PimcoreFormsBundle\Form\Transformer\ChoiceTransformer + parent: Limenius\Liform\Transformer\AbstractTransformer + tags: + - { name: liform.transformer, form_type: choice } + + liform.transformer.string: + class: Valantic\PimcoreFormsBundle\Form\Transformer\StringTransformer + parent: Limenius\Liform\Transformer\AbstractTransformer + tags: + - { name: liform.transformer, form_type: text } + - { name: liform.transformer, form_type: url, widget: url } + - { name: liform.transformer, form_type: search, widget: search } + - { name: liform.transformer, form_type: money, widget: money } + - { name: liform.transformer, form_type: password, widget: password } + - { name: liform.transformer, form_type: textarea, widget: textarea } + - { name: liform.transformer, form_type: time, widget: time } + - { name: liform.transformer, form_type: percent, widget: percent } + - { name: liform.transformer, form_type: email, widget: email } + + liform.transformer.number: + class: Valantic\PimcoreFormsBundle\Form\Transformer\NumberTransformer + parent: Limenius\Liform\Transformer\AbstractTransformer + tags: + - { name: liform.transformer, form_type: number } + + liform.transformer.boolean: + class: Valantic\PimcoreFormsBundle\Form\Transformer\BooleanTransformer + parent: Limenius\Liform\Transformer\AbstractTransformer + tags: + - { name: liform.transformer, form_type: checkbox, widget: checkbox } diff --git a/src/Resources/config/pimcore/routing.yml b/src/Resources/config/pimcore/routing.yml index 840d169..7e851d6 100644 --- a/src/Resources/config/pimcore/routing.yml +++ b/src/Resources/config/pimcore/routing.yml @@ -1,2 +1,4 @@ -forms: - resource: "@ValanticPimcoreFormsBundle/Resources/config/routing.yml" +valantic_app_usage: + resource: "@ValanticPimcoreFormsBundle/Controller/" + type: attribute + prefix: /valantic-forms diff --git a/src/Resources/config/routing.yml b/src/Resources/config/routing.yml deleted file mode 100644 index 3937e70..0000000 --- a/src/Resources/config/routing.yml +++ /dev/null @@ -1,4 +0,0 @@ -controllers: - resource: '../../Controller/' - type: annotation - prefix: /valantic-forms diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yaml similarity index 100% rename from src/Resources/config/services.yml rename to src/Resources/config/services.yaml diff --git a/src/Resources/config/transformers.yml b/src/Resources/config/transformers.yaml similarity index 100% rename from src/Resources/config/transformers.yml rename to src/Resources/config/transformers.yaml diff --git a/src/Service/FormService.php b/src/Service/FormService.php index 125ceb9..f3bf716 100644 --- a/src/Service/FormService.php +++ b/src/Service/FormService.php @@ -46,7 +46,7 @@ public function __construct( ChoiceTypeExtension $choiceTypeExtension, HiddenTypeExtension $hiddenTypeExtension, FormDataExtension $formDataExtension, - protected RequestStack $requestStack + protected RequestStack $requestStack, ) { $liform->addExtension($formTypeExtension); $liform->addExtension($formNameExtension); @@ -135,13 +135,19 @@ public function buildForm(string $name): FormInterface * Sample for a valid template string: '(%2$s) %1$s' * Sample result for example in German: '(Dateiupload) Die Datei ist gross (12MB), die maximal zulässige Grösse beträgt 10MB.' * - * @throws SerializerException - * * @return array + * + * @throws SerializerException */ public function errors(FormInterface $form): array { - return $this->errorNormalizer->normalize($form); + $normalized = $this->errorNormalizer->normalize($form); + + if (!is_array($normalized)) { + throw new \RuntimeException(); + } + + return $normalized; } public function outputs(FormInterface $form): OutputResponse @@ -150,6 +156,7 @@ public function outputs(FormInterface $form): OutputResponse $outputs = $this->getConfig($form->getName())['outputs']; $handlers = []; + foreach ($outputs as $name => ['type' => $type, 'options' => $options]) { $output = $this->outputRepository->get($type); $output->initialize($name, $form, $options); @@ -182,7 +189,7 @@ public function getRedirectUrl(FormInterface $form, bool $success): ?string */ protected function getConfig(string $name): array { - $config = $this->configurationRepository->get()['forms'][$name]; + $config = $this->configurationRepository->get()['forms'][$name] ?? null; if (empty($config) || !is_array($config)) { throw new InvalidFormConfigException($name); diff --git a/tests/Examples/CustomChoiceProvider/DatabaseChoiceProviderTest.php b/tests/Examples/CustomChoiceProvider/DatabaseChoiceProviderTest.php new file mode 100644 index 0000000..09883e3 --- /dev/null +++ b/tests/Examples/CustomChoiceProvider/DatabaseChoiceProviderTest.php @@ -0,0 +1,196 @@ +connection = $this->createMock(Connection::class); + } + + /** + * Test loading choices from database. + */ + public function testGetChoicesLoadsFromDatabase(): void + { + $result = $this->createMock(Result::class); + $result->method('fetchAllAssociative')->willReturn([ + ['id' => 1, 'name' => 'Sales'], + ['id' => 2, 'name' => 'Support'], + ['id' => 3, 'name' => 'Engineering'], + ]); + + $qb = $this->createMock(QueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('orderBy')->willReturnSelf(); + $qb->method('executeQuery')->willReturn($result); + + $this->connection + ->method('createQueryBuilder') + ->willReturn($qb) + ; + + $provider = new DatabaseChoiceProvider( + $this->connection, + 'departments', + 'id', + 'name', + ); + + $choices = $provider->getChoices(); + + $this->assertCount(3, $choices); + $this->assertEquals(1, $choices['Sales']); + $this->assertEquals(2, $choices['Support']); + $this->assertEquals(3, $choices['Engineering']); + } + + /** + * Test empty database returns empty choices. + */ + public function testGetChoicesWithEmptyDatabaseReturnsEmpty(): void + { + $result = $this->createMock(Result::class); + $result->method('fetchAllAssociative')->willReturn([]); + + $qb = $this->createMock(QueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('orderBy')->willReturnSelf(); + $qb->method('executeQuery')->willReturn($result); + + $this->connection + ->method('createQueryBuilder') + ->willReturn($qb) + ; + + $provider = new DatabaseChoiceProvider( + $this->connection, + 'departments', + 'id', + 'name', + ); + + $choices = $provider->getChoices(); + + $this->assertEmpty($choices); + } + + /** + * Test database exception returns empty choices. + */ + public function testGetChoicesWithDatabaseExceptionReturnsEmpty(): void + { + $this->connection + ->method('createQueryBuilder') + ->willThrowException(new \RuntimeException('Database connection failed')) + ; + + $provider = new DatabaseChoiceProvider( + $this->connection, + 'departments', + 'id', + 'name', + ); + + $choices = $provider->getChoices(); + + $this->assertEmpty($choices); + } + + /** + * Test custom order by column. + */ + public function testGetChoicesWithCustomOrderBy(): void + { + $result = $this->createMock(Result::class); + $result->method('fetchAllAssociative')->willReturn([ + ['id' => 1, 'name' => 'Sales', 'priority' => 10], + ['id' => 2, 'name' => 'Support', 'priority' => 5], + ]); + + $qb = $this->createMock(QueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->expects($this->once()) + ->method('orderBy') + ->with('priority', 'ASC') + ->willReturnSelf() + ; + $qb->method('executeQuery')->willReturn($result); + + $this->connection + ->method('createQueryBuilder') + ->willReturn($qb) + ; + + $provider = new DatabaseChoiceProvider( + $this->connection, + 'departments', + 'id', + 'name', + 'priority', + ); + + $choices = $provider->getChoices(); + + $this->assertCount(2, $choices); + } + + /** + * Test choices format matches Symfony expectations. + */ + public function testGetChoicesReturnsSymfonyFormat(): void + { + $result = $this->createMock(Result::class); + $result->method('fetchAllAssociative')->willReturn([ + ['id' => 'sales', 'name' => 'Sales Department'], + ['id' => 'support', 'name' => 'Support Team'], + ]); + + $qb = $this->createMock(QueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('orderBy')->willReturnSelf(); + $qb->method('executeQuery')->willReturn($result); + + $this->connection + ->method('createQueryBuilder') + ->willReturn($qb) + ; + + $provider = new DatabaseChoiceProvider( + $this->connection, + 'departments', + 'id', + 'name', + ); + + $choices = $provider->getChoices(); + + // Symfony choice format is ['Label' => 'value'] + $this->assertArrayHasKey('Sales Department', $choices); + $this->assertArrayHasKey('Support Team', $choices); + $this->assertEquals('sales', $choices['Sales Department']); + $this->assertEquals('support', $choices['Support Team']); + } +} diff --git a/tests/Examples/CustomInputHandler/QueryStringInputHandlerTest.php b/tests/Examples/CustomInputHandler/QueryStringInputHandlerTest.php new file mode 100644 index 0000000..88360c6 --- /dev/null +++ b/tests/Examples/CustomInputHandler/QueryStringInputHandlerTest.php @@ -0,0 +1,180 @@ +formFactory = Forms::createFormFactory(); + } + + /** + * Test handler pre-populates form fields from query parameters. + */ + public function testHandlePopulatesFormFieldsFromQueryParams(): void + { + $request = Request::create('/contact', 'GET', [ + 'utm_source' => 'newsletter', + 'utm_campaign' => 'spring2024', + 'ref' => 'FRIEND123', + ]); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('source', TextType::class) + ->add('campaign', TextType::class) + ->add('referralCode', TextType::class) + ->getForm() + ; + + $handler = new QueryStringInputHandler(); + + $config = [ + 'mapping' => [ + 'utm_source' => 'source', + 'utm_campaign' => 'campaign', + 'ref' => 'referralCode', + ], + ]; + + $handler->handle($request, $form, $config); + + $data = $form->getData(); + $this->assertEquals('newsletter', $data['source']); + $this->assertEquals('spring2024', $data['campaign']); + $this->assertEquals('FRIEND123', $data['referralCode']); + } + + /** + * Test handler ignores unmapped query parameters. + */ + public function testHandleIgnoresUnmappedQueryParams(): void + { + $request = Request::create('/contact', 'GET', [ + 'utm_source' => 'newsletter', + 'other_param' => 'ignored', + ]); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('source', TextType::class) + ->getForm() + ; + + $handler = new QueryStringInputHandler(); + + $config = [ + 'mapping' => [ + 'utm_source' => 'source', + ], + ]; + + $handler->handle($request, $form, $config); + + $data = $form->getData(); + $this->assertEquals('newsletter', $data['source']); + $this->assertArrayNotHasKey('other_param', $data); + } + + /** + * Test handler skips query params for non-existent form fields. + */ + public function testHandleSkipsNonExistentFormFields(): void + { + $request = Request::create('/contact', 'GET', [ + 'utm_source' => 'newsletter', + ]); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('email', TextType::class) + ->getForm() + ; + + $handler = new QueryStringInputHandler(); + + $config = [ + 'mapping' => [ + 'utm_source' => 'nonexistent_field', + ], + ]; + + $handler->handle($request, $form, $config); + + $data = $form->getData(); + $this->assertArrayNotHasKey('nonexistent_field', $data ?? []); + } + + /** + * Test handler with empty mapping does nothing. + */ + public function testHandleWithEmptyMappingDoesNothing(): void + { + $request = Request::create('/contact', 'GET', [ + 'utm_source' => 'newsletter', + ]); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('source', TextType::class) + ->getForm() + ; + + $handler = new QueryStringInputHandler(); + + $config = []; + + $handler->handle($request, $form, $config); + + $data = $form->getData(); + $this->assertNull($data); + } + + /** + * Test handler merges with existing form data. + */ + public function testHandleMergesWithExistingFormData(): void + { + $request = Request::create('/contact', 'GET', [ + 'utm_source' => 'newsletter', + ]); + + $form = $this->formFactory->createBuilder(FormType::class, [ + 'email' => 'john@example.com', + ]) + ->add('email', TextType::class) + ->add('source', TextType::class) + ->getForm() + ; + + $handler = new QueryStringInputHandler(); + + $config = [ + 'mapping' => [ + 'utm_source' => 'source', + ], + ]; + + $handler->handle($request, $form, $config); + + $data = $form->getData(); + $this->assertEquals('john@example.com', $data['email']); + $this->assertEquals('newsletter', $data['source']); + } +} diff --git a/tests/Examples/CustomOutput/SlackNotificationOutputTest.php b/tests/Examples/CustomOutput/SlackNotificationOutputTest.php new file mode 100644 index 0000000..53693d2 --- /dev/null +++ b/tests/Examples/CustomOutput/SlackNotificationOutputTest.php @@ -0,0 +1,284 @@ +formFactory = Forms::createFormFactory(); + $this->httpClient = $this->createMock(HttpClientInterface::class); + } + + /** + * Test successful Slack notification. + */ + public function testExecuteSendsSlackNotificationSuccessfully(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + + $this->httpClient + ->expects($this->once()) + ->method('request') + ->with( + 'POST', + 'https://hooks.slack.com/services/TEST/WEBHOOK/URL', + $this->callback(function ($options) { + $this->assertArrayHasKey('json', $options); + $this->assertArrayHasKey('timeout', $options); + $this->assertEquals(5, $options['timeout']); + + $json = $options['json']; + $this->assertArrayHasKey('text', $json); + $this->assertArrayHasKey('attachments', $json); + + return true; + }), + ) + ->willReturn($response) + ; + + $output = new SlackNotificationOutput($this->httpClient); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('name', TextType::class) + ->add('email', EmailType::class) + ->add('message', TextareaType::class) + ->add('submit', SubmitType::class) + ->getForm() + ; + + $form->submit([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Hello World', + ]); + + $config = [ + 'webhookUrl' => 'https://hooks.slack.com/services/TEST/WEBHOOK/URL', + ]; + + $status = $output->execute($form, $config); + + $this->assertTrue($status->success); + $this->assertEquals('Slack notification sent successfully', $status->message); + } + + /** + * Test missing webhook URL returns error. + */ + public function testExecuteWithMissingWebhookUrlReturnsError(): void + { + $output = new SlackNotificationOutput($this->httpClient); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('name', TextType::class) + ->getForm() + ; + + $form->submit(['name' => 'Test']); + + $status = $output->execute($form, []); + + $this->assertFalse($status->success); + $this->assertEquals('Slack webhook URL is not configured', $status->message); + } + + /** + * Test Slack API error returns error status. + */ + public function testExecuteWithSlackApiErrorReturnsError(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(500); + + $this->httpClient + ->method('request') + ->willReturn($response) + ; + + $output = new SlackNotificationOutput($this->httpClient); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('name', TextType::class) + ->getForm() + ; + + $form->submit(['name' => 'Test']); + + $config = [ + 'webhookUrl' => 'https://hooks.slack.com/services/TEST/WEBHOOK/URL', + ]; + + $status = $output->execute($form, $config); + + $this->assertFalse($status->success); + $this->assertStringContainsString('Slack API returned status 500', $status->message); + } + + /** + * Test HTTP exception is caught and returned as error. + */ + public function testExecuteWithHttpExceptionReturnsError(): void + { + $this->httpClient + ->method('request') + ->willThrowException(new \RuntimeException('Connection failed')) + ; + + $output = new SlackNotificationOutput($this->httpClient); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('name', TextType::class) + ->getForm() + ; + + $form->submit(['name' => 'Test']); + + $config = [ + 'webhookUrl' => 'https://hooks.slack.com/services/TEST/WEBHOOK/URL', + ]; + + $status = $output->execute($form, $config); + + $this->assertFalse($status->success); + $this->assertStringContainsString('Connection failed', $status->message); + } + + /** + * Test optional Slack configuration is included. + */ + public function testExecuteIncludesOptionalSlackConfiguration(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + + $this->httpClient + ->expects($this->once()) + ->method('request') + ->with( + 'POST', + 'https://hooks.slack.com/services/TEST/WEBHOOK/URL', + $this->callback(function ($options) { + $json = $options['json']; + $this->assertEquals('#notifications', $json['channel']); + $this->assertEquals('Form Bot', $json['username']); + $this->assertEquals(':robot_face:', $json['icon_emoji']); + + return true; + }), + ) + ->willReturn($response) + ; + + $output = new SlackNotificationOutput($this->httpClient); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('name', TextType::class) + ->getForm() + ; + + $form->submit(['name' => 'Test']); + + $config = [ + 'webhookUrl' => 'https://hooks.slack.com/services/TEST/WEBHOOK/URL', + 'channel' => '#notifications', + 'username' => 'Form Bot', + 'icon' => ':robot_face:', + ]; + + $status = $output->execute($form, $config); + + $this->assertTrue($status->success); + } + + /** + * Test form fields are properly formatted in Slack message. + */ + public function testExecuteFormatsFormFieldsProperly(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + + $this->httpClient + ->expects($this->once()) + ->method('request') + ->with( + 'POST', + 'https://hooks.slack.com/services/TEST/WEBHOOK/URL', + $this->callback(function ($options) { + $json = $options['json']; + $fields = $json['attachments'][0]['fields']; + + // Check that fields are present and formatted + $this->assertCount(3, $fields); // name, email, message (submit button excluded) + + // Check field structure + foreach ($fields as $field) { + $this->assertArrayHasKey('title', $field); + $this->assertArrayHasKey('value', $field); + $this->assertArrayHasKey('short', $field); + } + + // Check humanized field names + $titles = array_column($fields, 'title'); + $this->assertContains('Name', $titles); + $this->assertContains('Email', $titles); + $this->assertContains('Message', $titles); + + return true; + }), + ) + ->willReturn($response) + ; + + $output = new SlackNotificationOutput($this->httpClient); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('name', TextType::class) + ->add('email', EmailType::class) + ->add('message', TextareaType::class) + ->add('submit', SubmitType::class) + ->getForm() + ; + + $form->submit([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Hello World', + ]); + + $config = [ + 'webhookUrl' => 'https://hooks.slack.com/services/TEST/WEBHOOK/URL', + ]; + + $status = $output->execute($form, $config); + + $this->assertTrue($status->success); + } +} diff --git a/tests/Examples/CustomRedirectHandler/ConditionalRedirectHandlerTest.php b/tests/Examples/CustomRedirectHandler/ConditionalRedirectHandlerTest.php new file mode 100644 index 0000000..a15a6f2 --- /dev/null +++ b/tests/Examples/CustomRedirectHandler/ConditionalRedirectHandlerTest.php @@ -0,0 +1,175 @@ +formFactory = Forms::createFormFactory(); + } + + /** + * Test redirect based on field value condition. + */ + public function testGetRedirectUrlMatchesCondition(): void + { + $request = Request::create('/contact', 'POST'); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('inquiry_type', ChoiceType::class, [ + 'choices' => ['Sales' => 'sales', 'Support' => 'support'], + ]) + ->getForm() + ; + + $form->submit(['inquiry_type' => 'sales']); + + $handler = new ConditionalRedirectHandler(); + + $config = [ + 'conditions' => [ + ['field' => 'inquiry_type', 'value' => 'sales', 'url' => '/thank-you/sales'], + ['field' => 'inquiry_type', 'value' => 'support', 'url' => '/thank-you/support'], + ], + 'defaultUrl' => '/thank-you', + ]; + + $url = $handler->getRedirectUrl($request, $form, true, $config); + + $this->assertEquals('/thank-you/sales', $url); + } + + /** + * Test default URL when no conditions match. + */ + public function testGetRedirectUrlReturnsDefaultWhenNoMatch(): void + { + $request = Request::create('/contact', 'POST'); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('inquiry_type', TextType::class) + ->getForm() + ; + + $form->submit(['inquiry_type' => 'other']); + + $handler = new ConditionalRedirectHandler(); + + $config = [ + 'conditions' => [ + ['field' => 'inquiry_type', 'value' => 'sales', 'url' => '/thank-you/sales'], + ['field' => 'inquiry_type', 'value' => 'support', 'url' => '/thank-you/support'], + ], + 'defaultUrl' => '/thank-you', + ]; + + $url = $handler->getRedirectUrl($request, $form, true, $config); + + $this->assertEquals('/thank-you', $url); + } + + /** + * Test error URL on form submission failure. + */ + public function testGetRedirectUrlReturnsErrorUrlOnFailure(): void + { + $request = Request::create('/contact', 'POST'); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('inquiry_type', TextType::class) + ->getForm() + ; + + $form->submit(['inquiry_type' => 'sales']); + + $handler = new ConditionalRedirectHandler(); + + $config = [ + 'conditions' => [ + ['field' => 'inquiry_type', 'value' => 'sales', 'url' => '/thank-you/sales'], + ], + 'defaultUrl' => '/thank-you', + 'errorUrl' => '/error', + ]; + + $url = $handler->getRedirectUrl($request, $form, false, $config); + + $this->assertEquals('/error', $url); + } + + /** + * Test null return when no default URL configured. + */ + public function testGetRedirectUrlReturnsNullWhenNoDefault(): void + { + $request = Request::create('/contact', 'POST'); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('inquiry_type', TextType::class) + ->getForm() + ; + + $form->submit(['inquiry_type' => 'other']); + + $handler = new ConditionalRedirectHandler(); + + $config = [ + 'conditions' => [ + ['field' => 'inquiry_type', 'value' => 'sales', 'url' => '/thank-you/sales'], + ], + ]; + + $url = $handler->getRedirectUrl($request, $form, true, $config); + + $this->assertNull($url); + } + + /** + * Test case-insensitive string matching. + */ + public function testGetRedirectUrlMatchesCaseInsensitive(): void + { + $request = Request::create('/contact', 'POST'); + + $form = $this->formFactory->createBuilder(FormType::class, null) + ->add('inquiry_type', TextType::class) + ->getForm() + ; + + $form->submit(['inquiry_type' => 'SALES']); + + $handler = new ConditionalRedirectHandler(); + + $config = [ + 'conditions' => [ + ['field' => 'inquiry_type', 'value' => 'sales', 'url' => '/thank-you/sales'], + ], + 'defaultUrl' => '/thank-you', + ]; + + $url = $handler->getRedirectUrl($request, $form, true, $config); + + $this->assertEquals('/thank-you/sales', $url); + } +} diff --git a/tests/Examples/Form/Choices/DatabaseChoiceProvider.php b/tests/Examples/Form/Choices/DatabaseChoiceProvider.php new file mode 100644 index 0000000..49ff92e --- /dev/null +++ b/tests/Examples/Form/Choices/DatabaseChoiceProvider.php @@ -0,0 +1,96 @@ +connection = $connection; + $this->table = $table; + $this->valueColumn = $valueColumn; + $this->labelColumn = $labelColumn; + $this->orderBy = $orderBy ?? $labelColumn; + } + + /** + * @return array + */ + public function getChoices(): array + { + try { + $qb = $this->connection->createQueryBuilder(); + + $qb->select($this->valueColumn, $this->labelColumn) + ->from($this->table) + ->orderBy($this->orderBy, 'ASC') + ; + + $results = $qb->executeQuery()->fetchAllAssociative(); + + // Build choices array in Symfony format: ['label' => 'value'] + $choices = []; + + foreach ($results as $row) { + $label = $row[$this->labelColumn] ?? ''; + $value = $row[$this->valueColumn] ?? ''; + $choices[$label] = $value; + } + + return $choices; + } catch (\Exception $e) { + // Log error and return empty choices + // In production, you might want to use a logger here + return []; + } + } +} diff --git a/tests/Examples/Form/InputHandler/QueryStringInputHandler.php b/tests/Examples/Form/InputHandler/QueryStringInputHandler.php new file mode 100644 index 0000000..1c6abd8 --- /dev/null +++ b/tests/Examples/Form/InputHandler/QueryStringInputHandler.php @@ -0,0 +1,80 @@ +} $config + */ + public function handle(Request $request, FormInterface $form, array $config): void + { + $mapping = $config['mapping'] ?? []; + + if (empty($mapping)) { + return; + } + + $data = []; + + // Map query parameters to form field names + foreach ($mapping as $queryParam => $formField) { + $value = $request->query->get($queryParam); + + if ($value !== null && $form->has($formField)) { + $data[$formField] = $value; + } + } + + // Only set data if we have any + if (!empty($data)) { + // Merge with existing data to avoid overwriting + $existingData = $form->getData() ?? []; + $mergedData = array_merge($existingData, $data); + + $form->setData($mergedData); + } + } +} diff --git a/tests/Examples/Form/Output/SlackNotificationOutput.php b/tests/Examples/Form/Output/SlackNotificationOutput.php new file mode 100644 index 0000000..8929045 --- /dev/null +++ b/tests/Examples/Form/Output/SlackNotificationOutput.php @@ -0,0 +1,160 @@ +httpClient = $httpClient; + } + + /** + * @param array{webhookUrl: string, channel?: string, username?: string, icon?: string} $config + */ + public function execute(FormInterface $form, array $config): OutputStatus + { + try { + $webhookUrl = $config['webhookUrl'] ?? ''; + + if (empty($webhookUrl)) { + return OutputStatus::error('Slack webhook URL is not configured'); + } + + // Build the Slack message payload + $payload = $this->buildSlackPayload($form, $config); + + // Send to Slack + $response = $this->httpClient->request('POST', $webhookUrl, [ + 'json' => $payload, + 'timeout' => 5, + ]); + + if ($response->getStatusCode() === 200) { + return OutputStatus::success('Slack notification sent successfully'); + } + + return OutputStatus::error( + sprintf('Slack API returned status %d', $response->getStatusCode()), + ); + } catch (\Exception $e) { + return OutputStatus::error( + sprintf('Failed to send Slack notification: %s', $e->getMessage()), + ); + } + } + + /** + * @param array{channel?: string, username?: string, icon?: string} $config + * + * @return array + */ + private function buildSlackPayload(FormInterface $form, array $config): array + { + $formData = $form->getData(); + $formName = $form->getConfig()->getName(); + + // Build formatted fields for Slack + $fields = []; + + foreach ($form->all() as $field) { + if ($field->getConfig()->getType()->getInnerType()::class === \Symfony\Component\Form\Extension\Core\Type\SubmitType::class) { + continue; + } + + $fieldName = $field->getName(); + $value = $formData[$fieldName] ?? ''; + + if (is_array($value)) { + $value = implode(', ', $value); + } + + $fields[] = [ + 'title' => $this->humanize($fieldName), + 'value' => (string) $value, + 'short' => mb_strlen((string) $value) < 40, + ]; + } + + // Build the Slack message + $payload = [ + 'text' => sprintf('New form submission: *%s*', $formName), + 'attachments' => [ + [ + 'color' => 'good', + 'fields' => $fields, + 'footer' => 'Pimcore Forms', + 'ts' => time(), + ], + ], + ]; + + // Add optional configuration + if (!empty($config['channel'])) { + $payload['channel'] = $config['channel']; + } + + if (!empty($config['username'])) { + $payload['username'] = $config['username']; + } + + if (!empty($config['icon'])) { + $payload['icon_emoji'] = $config['icon']; + } + + return $payload; + } + + /** + * Convert a field name to human-readable format. + * Example: "firstName" => "First Name". + */ + private function humanize(string $text): string + { + // Split camelCase and snake_case + $text = preg_replace('/([a-z])([A-Z])/', '$1 $2', $text); + $text = str_replace('_', ' ', $text); + + // Capitalize words + return ucwords(strtolower($text)); + } +} diff --git a/tests/Examples/Form/RedirectHandler/ConditionalRedirectHandler.php b/tests/Examples/Form/RedirectHandler/ConditionalRedirectHandler.php new file mode 100644 index 0000000..96f6b59 --- /dev/null +++ b/tests/Examples/Form/RedirectHandler/ConditionalRedirectHandler.php @@ -0,0 +1,100 @@ +, defaultUrl?: string} $config + */ + public function getRedirectUrl(Request $request, FormInterface $form, bool $success, array $config): ?string + { + // If form submission failed, use default error URL or return null + if (!$success) { + return $config['errorUrl'] ?? null; + } + + $conditions = $config['conditions'] ?? []; + $defaultUrl = $config['defaultUrl'] ?? null; + + $formData = $form->getData(); + + // Check each condition + foreach ($conditions as $condition) { + $field = $condition['field'] ?? null; + $expectedValue = $condition['value'] ?? null; + $url = $condition['url'] ?? null; + + if ($field === null || $url === null) { + continue; + } + + $actualValue = $formData[$field] ?? null; + + // Check if condition matches + if ($this->matchesCondition($actualValue, $expectedValue)) { + return $url; + } + } + + // No conditions matched, return default URL + return $defaultUrl; + } + + /** + * Check if actual value matches expected value. + */ + private function matchesCondition($actual, $expected): bool + { + // Handle array values (e.g., multi-choice fields) + if (is_array($actual)) { + return in_array($expected, $actual, true); + } + + // Handle string comparison (case-insensitive) + if (is_string($actual) && is_string($expected)) { + return strcasecmp($actual, $expected) === 0; + } + + // Handle exact match for other types + return $actual === $expected; + } +} diff --git a/tests/Examples/README.md b/tests/Examples/README.md new file mode 100644 index 0000000..c0f1524 --- /dev/null +++ b/tests/Examples/README.md @@ -0,0 +1,375 @@ +# Extension Examples + +This directory contains practical examples demonstrating how to extend the Pimcore Forms bundle with custom functionality. Each example includes a working implementation and comprehensive test coverage. + +## Available Examples + +### 1. Custom Output Handler - Slack Notifications + +**Location:** `CustomOutput/SlackNotificationOutput.php` + +Sends form submissions to a Slack channel via webhook. + +**Use Case:** Real-time notifications of form submissions to your team's Slack workspace. + +**Implementation:** +```php +createMock(ConfigurationRepository::class); + $configRepo->method('get') + ->willReturn(ConfigurationFactory::createContactFormConfig()) + ; + + $outputRepo = $this->createMock(OutputRepository::class); + $inputHandlerRepo = $this->createMock(InputHandlerRepository::class); + $redirectHandlerRepo = $this->createMock(RedirectHandlerRepository::class); + $builder = $this->createMock(Builder::class); + $liform = $this->createMock(Liform::class); + $errorNormalizer = $this->createMock(FormErrorNormalizer::class); + $requestStack = $this->createMock(RequestStack::class); + + $this->formService = new FormService( + $configRepo, + $outputRepo, + $redirectHandlerRepo, + $inputHandlerRepo, + $builder, + $liform, + $errorNormalizer, + $this->createMock(FormTypeExtension::class), + $this->createMock(FormNameExtension::class), + $this->createMock(FormConstraintExtension::class), + $this->createMock(FormAttributeExtension::class), + $this->createMock(ChoiceTypeExtension::class), + $this->createMock(HiddenTypeExtension::class), + $this->createMock(FormDataExtension::class), + $requestStack, + ); + + $this->translator = $this->createMock(TranslatorInterface::class); + $this->controller = new FormController(); + } + + /** + * Test API endpoint returns JSON response with correct content type. + */ + public function testApiEndpointReturnsJsonContentType(): void + { + $request = Request::create('/form/api/contact', 'GET'); + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals('application/json', $response->headers->get('Content-Type')); + } + + /** + * Test API endpoint returns 404 for non-existent form. + */ + public function testApiEndpointReturns404ForNonExistentForm(): void + { + $configRepo = $this->createMock(ConfigurationRepository::class); + $configRepo->method('get') + ->willReturn(ConfigurationFactory::createContactFormConfig()) + ; + + $outputRepo = $this->createMock(OutputRepository::class); + $inputHandlerRepo = $this->createMock(InputHandlerRepository::class); + $redirectHandlerRepo = $this->createMock(RedirectHandlerRepository::class); + $builder = $this->createMock(Builder::class); + $liform = $this->createMock(Liform::class); + $errorNormalizer = $this->createMock(FormErrorNormalizer::class); + $requestStack = $this->createMock(RequestStack::class); + + $formService = new FormService( + $configRepo, + $outputRepo, + $redirectHandlerRepo, + $inputHandlerRepo, + $builder, + $liform, + $errorNormalizer, + $this->createMock(FormTypeExtension::class), + $this->createMock(FormNameExtension::class), + $this->createMock(FormConstraintExtension::class), + $this->createMock(FormAttributeExtension::class), + $this->createMock(ChoiceTypeExtension::class), + $this->createMock(HiddenTypeExtension::class), + $this->createMock(FormDataExtension::class), + $requestStack, + ); + + $controller = new FormController(); + + $request = Request::create('/form/api/nonexistent', 'GET'); + + $translator = $this->createMock(TranslatorInterface::class); + $this->expectException(InvalidFormConfigException::class); + $controller->apiAction('nonexistent', $formService, $request, $translator); + } + + /** + * Test API endpoint handles malformed JSON with 400 error. + */ + public function testApiEndpointHandlesMalformedJson(): void + { + $request = Request::create('/form/api/contact', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], '{"invalid": json}'); + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + $this->assertEquals(412, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertFalse($data['success']); + } + + /** + * Test API endpoint accepts form-urlencoded data. + */ + public function testApiEndpointAcceptsFormUrlencodedData(): void + { + $request = Request::create('/form/api/contact', 'POST', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + ], [], [], [ + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded', + ]); + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + $this->assertEquals(200, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertTrue($data['success']); + } + + /** + * Test API endpoint returns validation errors in proper format. + */ + public function testApiEndpointReturnsProperErrorFormat(): void + { + $request = Request::create('/form/api/contact', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'name' => '', + 'email' => 'invalid', + ])); + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + $this->assertEquals(412, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertArrayHasKey('success', $data); + $this->assertFalse($data['success']); + $this->assertArrayHasKey('errors', $data); + $this->assertIsArray($data['errors']); + } + + /** + * Test API endpoint supports CORS preflight requests. + */ + public function testApiEndpointSupportsCorsHeaders(): void + { + $request = Request::create('/form/api/contact', 'GET', [], [], [], [ + 'HTTP_ORIGIN' => 'https://example.com', + ]); + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + // Note: CORS headers would typically be added by middleware/event listeners + // This test just verifies the endpoint responds to requests with Origin header + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * Test API endpoint returns success response structure. + */ + public function testApiEndpointReturnsSuccessStructure(): void + { + $request = Request::create('/form/api/contact', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + ])); + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + $this->assertEquals(200, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertArrayHasKey('success', $data); + $this->assertTrue($data['success']); + $this->assertArrayNotHasKey('errors', $data); + } + + /** + * Test API endpoint handles GET and POST methods only. + */ + public function testApiEndpointOnlySupportsGetAndPost(): void + { + $methods = ['PUT', 'DELETE', 'PATCH']; + + foreach ($methods as $method) { + $request = Request::create('/form/api/contact', $method); + + // The controller should handle these, but GET/POST are the primary methods + // This test documents the expected behavior + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + // PUT/DELETE/PATCH will be treated like GET, returning schema + $this->assertInstanceOf(Response::class, $response); + } + } +} diff --git a/tests/Functional/FormSubmissionFlowTest.php b/tests/Functional/FormSubmissionFlowTest.php new file mode 100644 index 0000000..54b49f8 --- /dev/null +++ b/tests/Functional/FormSubmissionFlowTest.php @@ -0,0 +1,372 @@ +setSession($session); + $requestStack->push($request); + + $csrfTokenManager = new CsrfTokenManager( + new UriSafeTokenGenerator(), + new SessionTokenStorage($requestStack), + ); + + $this->formFactory = Forms::createFormFactoryBuilder() + ->addExtension(new CsrfExtension($csrfTokenManager)) + ->getFormFactory() + ; + + // Create repositories + $configRepo = $this->createMock(ConfigurationRepository::class); + $configRepo->method('get') + ->willReturn(ConfigurationFactory::createContactFormConfig()) + ; + + $outputRepo = $this->createMock(OutputRepository::class); + $inputHandlerRepo = $this->createMock(InputHandlerRepository::class); + $redirectHandlerRepo = $this->createMock(RedirectHandlerRepository::class); + $builder = $this->createMock(Builder::class); + $liform = $this->createMock(Liform::class); + $errorNormalizer = $this->createMock(FormErrorNormalizer::class); + + // Create form service + $this->formService = new FormService( + $configRepo, + $outputRepo, + $redirectHandlerRepo, + $inputHandlerRepo, + $builder, + $liform, + $errorNormalizer, + $this->createMock(FormTypeExtension::class), + $this->createMock(FormNameExtension::class), + $this->createMock(FormConstraintExtension::class), + $this->createMock(FormAttributeExtension::class), + $this->createMock(ChoiceTypeExtension::class), + $this->createMock(HiddenTypeExtension::class), + $this->createMock(FormDataExtension::class), + $requestStack, + ); + + $this->translator = $this->createMock(TranslatorInterface::class); + // Create controller + $this->controller = new FormController(); + } + + /** + * Test complete GET schema flow. + */ + public function testGetSchemaReturnsJsonSchema(): void + { + $request = Request::create('/form/api/contact', 'GET'); + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('application/json', $response->headers->get('Content-Type')); + + $data = json_decode($response->getContent(), true); + $this->assertArrayHasKey('schema', $data); + $this->assertArrayHasKey('properties', $data['schema']); + } + + /** + * Test GET request returns form schema with all fields. + */ + public function testGetSchemaContainsAllFormFields(): void + { + $request = Request::create('/form/api/contact', 'GET'); + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + $data = json_decode($response->getContent(), true); + + $this->assertArrayHasKey('name', $data['schema']['properties']); + $this->assertArrayHasKey('email', $data['schema']['properties']); + $this->assertArrayHasKey('message', $data['schema']['properties']); + $this->assertArrayHasKey('required', $data['schema']); + $this->assertContains('name', $data['schema']['required']); + $this->assertContains('email', $data['schema']['required']); + } + + /** + * Test POST with valid data returns success. + */ + public function testPostValidDataReturnsSuccess(): void + { + $request = Request::create('/form/api/contact', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + ])); + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + $this->assertEquals(200, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertTrue($data['success']); + } + + /** + * Test POST with invalid data returns validation errors. + */ + public function testPostInvalidDataReturnsValidationErrors(): void + { + $request = Request::create('/form/api/contact', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'name' => '', + 'email' => 'invalid-email', + 'message' => '', + ])); + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + $this->assertEquals(412, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertFalse($data['success']); + $this->assertArrayHasKey('errors', $data); + $this->assertNotEmpty($data['errors']); + } + + /** + * Test POST with missing required fields returns errors. + */ + public function testPostMissingRequiredFieldsReturnsErrors(): void + { + $request = Request::create('/form/api/contact', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'name' => 'John Doe', + ])); + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + $this->assertEquals(412, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertFalse($data['success']); + $this->assertArrayHasKey('errors', $data); + } + + /** + * Test CSRF token validation on POST requests. + */ + public function testPostWithInvalidCsrfTokenReturnsError(): void + { + // Create config with CSRF enabled + $configRepo = $this->createMock(ConfigurationRepository::class); + $config = ConfigurationFactory::createContactFormConfig(); + $config['forms']['contact']['csrf'] = true; + $configRepo->method('get')->willReturn($config); + + $outputRepo = $this->createMock(OutputRepository::class); + $inputHandlerRepo = $this->createMock(InputHandlerRepository::class); + $redirectHandlerRepo = $this->createMock(RedirectHandlerRepository::class); + $builder = $this->createMock(Builder::class); + $liform = $this->createMock(Liform::class); + $errorNormalizer = $this->createMock(FormErrorNormalizer::class); + $requestStack = $this->createMock(RequestStack::class); + + $formService = new FormService( + $configRepo, + $outputRepo, + $redirectHandlerRepo, + $inputHandlerRepo, + $builder, + $liform, + $errorNormalizer, + $this->createMock(FormTypeExtension::class), + $this->createMock(FormNameExtension::class), + $this->createMock(FormConstraintExtension::class), + $this->createMock(FormAttributeExtension::class), + $this->createMock(ChoiceTypeExtension::class), + $this->createMock(HiddenTypeExtension::class), + $this->createMock(FormDataExtension::class), + $requestStack, + ); + + $translator = $this->createMock(TranslatorInterface::class); + $controller = new FormController(); + + $request = Request::create('/form/api/contact', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + '_token' => 'invalid_token', + ])); + + $response = $controller->apiAction('contact', $formService, $request, $translator); + + $this->assertEquals(412, $response->getStatusCode()); + } + + /** + * Test redirect URL is returned in successful response. + */ + public function testSuccessResponseContainsRedirectUrl(): void + { + $configRepo = $this->createMock(ConfigurationRepository::class); + $config = ConfigurationFactory::createContactFormConfig(); + $config['forms']['contact']['redirectUrl'] = '/thank-you'; + $configRepo->method('get')->willReturn($config); + + $outputRepo = $this->createMock(OutputRepository::class); + $inputHandlerRepo = $this->createMock(InputHandlerRepository::class); + $redirectHandlerRepo = $this->createMock(RedirectHandlerRepository::class); + $builder = $this->createMock(Builder::class); + $liform = $this->createMock(Liform::class); + $errorNormalizer = $this->createMock(FormErrorNormalizer::class); + $requestStack = $this->createMock(RequestStack::class); + + $formService = new FormService( + $configRepo, + $outputRepo, + $redirectHandlerRepo, + $inputHandlerRepo, + $builder, + $liform, + $errorNormalizer, + $this->createMock(FormTypeExtension::class), + $this->createMock(FormNameExtension::class), + $this->createMock(FormConstraintExtension::class), + $this->createMock(FormAttributeExtension::class), + $this->createMock(ChoiceTypeExtension::class), + $this->createMock(HiddenTypeExtension::class), + $this->createMock(FormDataExtension::class), + $requestStack, + ); + + $translator = $this->createMock(TranslatorInterface::class); + $controller = new FormController(); + + $request = Request::create('/form/api/contact', 'POST', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + ])); + + $response = $controller->apiAction('contact', $formService, $request, $translator); + + $this->assertEquals(200, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertArrayHasKey('redirect', $data); + $this->assertEquals('/thank-you', $data['redirect']); + } + + /** + * Test HTML action returns rendered form template. + */ + public function testHtmlActionReturnsFormTemplate(): void + { + $request = Request::create('/form/html/contact', 'GET'); + + $response = $this->controller->htmlAction('contact', $this->formService); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('text/html', $response->headers->get('Content-Type') ?? ''); + } + + /** + * Test Vue.js UI action returns JavaScript application. + */ + public function testUiActionReturnsVueApplication(): void + { + $request = Request::create('/form/ui/contact', 'GET'); + + $response = $this->controller->uiAction('contact'); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('text/html', $response->headers->get('Content-Type') ?? ''); + } + + /** + * Test mail document action returns email template. + */ + public function testMailDocumentActionReturnsEmailTemplate(): void + { + $request = new Request(); + $request->attributes->set('form_contents', '

Test content

'); + $request->attributes->set('_route', 'test_route'); + + $result = $this->controller->mailDocumentAction($request); + + $this->assertIsArray($result); + $this->assertArrayHasKey('form_contents', $result); + $this->assertEquals('

Test content

', $result['form_contents']); + $this->assertArrayNotHasKey('_route', $result); + } +} diff --git a/tests/Integration/FormBuildingIntegrationTest.php b/tests/Integration/FormBuildingIntegrationTest.php new file mode 100644 index 0000000..e35b710 --- /dev/null +++ b/tests/Integration/FormBuildingIntegrationTest.php @@ -0,0 +1,287 @@ + Builder -> Form flow. + */ +#[AllowMockObjectsWithoutExpectations] +class FormBuildingIntegrationTest extends TestCase +{ + private Builder $builder; + private MockObject $urlGenerator; + private MockObject $translator; + private MockObject $formFactory; + private MockObject $choicesRepository; + + protected function setUp(): void + { + $this->urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $this->translator = $this->createMock(TranslatorInterface::class); + $this->formFactory = $this->createMock(FormFactoryInterface::class); + $this->choicesRepository = $this->createMock(ChoicesRepository::class); + + $this->builder = new Builder( + $this->urlGenerator, + $this->translator, + $this->formFactory, + $this->choicesRepository, + ); + } + + public function testCompleteFormBuildingFlow(): void + { + $config = ConfigurationFactory::createValidFormConfig('contact'); + $formConfig = $config['forms']['contact']; + + $mockBuilder = $this->createMock(FormBuilderInterface::class); + $mockBuilder->method('setMethod')->willReturnSelf(); + $mockBuilder->method('setAction')->willReturnSelf(); + + $this->formFactory + ->expects($this->once()) + ->method('createNamedBuilder') + ->willReturn($mockBuilder) + ; + + $this->urlGenerator + ->method('generate') + ->willReturn('/api/contact') + ; + + $result = $this->builder->form('contact', $formConfig); + + $this->assertInstanceOf(FormBuilderInterface::class, $result); + } + + public function testFieldAdditionWithConstraints(): void + { + $config = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $config['forms']['test_form']; + + $mockBuilder = $this->createMock(FormBuilderInterface::class); + $mockBuilder->method('setMethod')->willReturnSelf(); + $mockBuilder->method('setAction')->willReturnSelf(); + + $this->formFactory + ->method('createNamedBuilder') + ->willReturn($mockBuilder) + ; + + $this->urlGenerator->method('generate')->willReturn('/api/test'); + + $form = $this->builder->form('test_form', $formConfig); + + // Test that we can get field configuration + $fieldDefinition = $formConfig['fields']['name']; + [$type, $options] = $this->builder->field('test_form', $fieldDefinition, $formConfig); + + $this->assertSame(TextType::class, $type); + $this->assertArrayHasKey('constraints', $options); + } + + public function testTranslationIntegration(): void + { + $config = ConfigurationFactory::createValidFormConfig('translated'); + $formConfig = $config['forms']['translated']; + + $this->translator + ->expects($this->atLeastOnce()) + ->method('trans') + ->willReturnCallback(fn ($key) => "translated_{$key}") + ; + + $mockBuilder = $this->createMock(FormBuilderInterface::class); + $mockBuilder->method('setMethod')->willReturnSelf(); + $mockBuilder->method('setAction')->willReturnSelf(); + + $this->formFactory + ->method('createNamedBuilder') + ->willReturn($mockBuilder) + ; + + $this->urlGenerator->method('generate')->willReturn('/api/translated'); + + $form = $this->builder->form('translated', $formConfig); + + $fieldDefinition = $formConfig['fields']['name']; + [$type, $options] = $this->builder->field('translated', $fieldDefinition, $formConfig); + + $this->assertIsArray($options); + } + + public function testMultipleFieldTypesBuilding(): void + { + $config = [ + 'forms' => [ + 'multi_type' => [ + 'csrf' => false, + 'method' => 'POST', + 'translate' => ['field_labels' => false, 'inline_choices' => false], + 'fields' => [ + 'text_field' => [ + 'type' => 'TextType', + 'options' => ['label' => 'Text'], + 'constraints' => [], + 'provider' => null, + ], + 'email_field' => [ + 'type' => 'EmailType', + 'options' => ['label' => 'Email'], + 'constraints' => [], + 'provider' => null, + ], + ], + 'outputs' => [], + 'redirect_url' => null, + 'redirect_handler' => null, + 'input_handler' => null, + ], + ], + ]; + + $mockBuilder = $this->createMock(FormBuilderInterface::class); + $mockBuilder->method('setMethod')->willReturnSelf(); + $mockBuilder->method('setAction')->willReturnSelf(); + + $this->formFactory + ->method('createNamedBuilder') + ->willReturn($mockBuilder) + ; + + $this->urlGenerator->method('generate')->willReturn('/api/multi'); + + $form = $this->builder->form('multi_type', $config['forms']['multi_type']); + + // Test text field + [$textType, $textOptions] = $this->builder->field( + 'multi_type', + $config['forms']['multi_type']['fields']['text_field'], + $config['forms']['multi_type'], + ); + $this->assertSame(TextType::class, $textType); + + // Test email field + [$emailType, $emailOptions] = $this->builder->field( + 'multi_type', + $config['forms']['multi_type']['fields']['email_field'], + $config['forms']['multi_type'], + ); + $this->assertSame(EmailType::class, $emailType); + } + + public function testFormBuilderWithCsrfEnabled(): void + { + $config = ConfigurationFactory::createValidFormConfig('csrf_form'); + $formConfig = $config['forms']['csrf_form']; + $formConfig['csrf'] = true; + + $mockBuilder = $this->createMock(FormBuilderInterface::class); + $mockBuilder->method('setMethod')->willReturnSelf(); + $mockBuilder->method('setAction')->willReturnSelf(); + + $this->formFactory + ->expects($this->once()) + ->method('createNamedBuilder') + ->with('csrf_form', $this->anything(), null, $this->callback(fn ($options) => $options['csrf_protection'] === true)) + ->willReturn($mockBuilder) + ; + + $this->urlGenerator->method('generate')->willReturn('/api/csrf_form'); + + $form = $this->builder->form('csrf_form', $formConfig); + + $this->assertInstanceOf(FormBuilderInterface::class, $form); + } + + public function testFormBuilderWithCsrfDisabled(): void + { + $config = ConfigurationFactory::createValidFormConfig('no_csrf_form'); + $formConfig = $config['forms']['no_csrf_form']; + $formConfig['csrf'] = false; + + $mockBuilder = $this->createMock(FormBuilderInterface::class); + $mockBuilder->method('setMethod')->willReturnSelf(); + $mockBuilder->method('setAction')->willReturnSelf(); + + $this->formFactory + ->expects($this->once()) + ->method('createNamedBuilder') + ->with('no_csrf_form', $this->anything(), null, $this->callback(fn ($options) => $options['csrf_protection'] === false)) + ->willReturn($mockBuilder) + ; + + $this->urlGenerator->method('generate')->willReturn('/api/no_csrf_form'); + + $form = $this->builder->form('no_csrf_form', $formConfig); + + $this->assertInstanceOf(FormBuilderInterface::class, $form); + } + + public function testFormActionUrlGeneration(): void + { + $config = ConfigurationFactory::createValidFormConfig('url_test'); + $formConfig = $config['forms']['url_test']; + + $mockBuilder = $this->createMock(FormBuilderInterface::class); + $mockBuilder->method('setMethod')->willReturnSelf(); + $mockBuilder->expects($this->once()) + ->method('setAction') + ->with('/api/forms/url_test') + ->willReturnSelf() + ; + + $this->formFactory + ->method('createNamedBuilder') + ->willReturn($mockBuilder) + ; + + $this->urlGenerator + ->expects($this->once()) + ->method('generate') + ->with('valantic_pimcoreforms_form_api', ['name' => 'url_test']) + ->willReturn('/api/forms/url_test') + ; + + $this->builder->form('url_test', $formConfig); + } + + public function testFormMethodConfiguration(): void + { + $config = ConfigurationFactory::createValidFormConfig('method_test'); + $formConfig = $config['forms']['method_test']; + $formConfig['method'] = 'GET'; + + $mockBuilder = $this->createMock(FormBuilderInterface::class); + $mockBuilder->expects($this->once()) + ->method('setMethod') + ->with('GET') + ->willReturnSelf() + ; + $mockBuilder->method('setAction')->willReturnSelf(); + + $this->formFactory + ->method('createNamedBuilder') + ->willReturn($mockBuilder) + ; + + $this->urlGenerator->method('generate')->willReturn('/api/method_test'); + + $this->builder->form('method_test', $formConfig); + } +} diff --git a/tests/Integration/JsonSchemaGenerationTest.php b/tests/Integration/JsonSchemaGenerationTest.php new file mode 100644 index 0000000..7d49f4a --- /dev/null +++ b/tests/Integration/JsonSchemaGenerationTest.php @@ -0,0 +1,277 @@ + JSON schema transformation. + */ +#[AllowMockObjectsWithoutExpectations] +class JsonSchemaGenerationTest extends TestCase +{ + private Liform $liform; + private Resolver $resolver; + private MockObject $translator; + private FormFactoryInterface $formFactory; + + protected function setUp(): void + { + $this->translator = $this->createMock(TranslatorInterface::class); + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $this->formFactory = Forms::createFormFactoryBuilder()->getFormFactory(); + $this->resolver = new Resolver(); + $this->liform = new Liform($this->resolver); + + // Register transformers - ArrayTransformer and CompoundTransformer need resolver + $this->resolver->setTransformer('text', new StringTransformer($this->translator, null)); + $this->resolver->setTransformer('textarea', new StringTransformer($this->translator, null)); + $this->resolver->setTransformer('email', new StringTransformer($this->translator, null)); + $this->resolver->setTransformer('integer', new IntegerTransformer($this->translator, null)); + $this->resolver->setTransformer('number', new NumberTransformer($this->translator, null)); + $this->resolver->setTransformer('choice', new ChoiceTransformer($this->translator, null)); + $this->resolver->setTransformer('checkbox', new BooleanTransformer($this->translator, null)); + $this->resolver->setTransformer('collection', new ArrayTransformer($this->translator, $this->resolver)); + $this->resolver->setTransformer('form', new CompoundTransformer($this->translator, $this->resolver)); + } + + public function testBasicTextFieldTransformation(): void + { + $form = $this->formFactory + ->createBuilder() + ->add('name', TextType::class, [ + 'label' => 'Full Name', + ]) + ->getForm() + ; + + $schema = $this->liform->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('object', $schema['type']); + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('name', $schema['properties']); + $this->assertSame('string', $schema['properties']['name']['type']); + $this->assertSame('Full Name', $schema['properties']['name']['title']); + } + + public function testComplexFormWithMultipleFieldTypes(): void + { + $form = $this->formFactory + ->createBuilder() + ->add('name', TextType::class, ['label' => 'Name']) + ->add('email', EmailType::class, ['label' => 'Email']) + ->add('age', IntegerType::class, ['label' => 'Age']) + ->add('subscribe', CheckboxType::class, ['label' => 'Subscribe']) + ->getForm() + ; + + $schema = $this->liform->transform($form); + + $this->assertIsArray($schema); + $this->assertArrayHasKey('properties', $schema); + $this->assertCount(4, $schema['properties']); + + // Check each field type + $this->assertSame('string', $schema['properties']['name']['type']); + $this->assertSame('string', $schema['properties']['email']['type']); + $this->assertSame('integer', $schema['properties']['age']['type']); + $this->assertSame('boolean', $schema['properties']['subscribe']['type']); + } + + public function testFormWithAttributes(): void + { + $form = $this->formFactory + ->createBuilder() + ->add('username', TextType::class, [ + 'label' => 'Username', + 'attr' => [ + 'minlength' => 3, + 'maxlength' => 20, + ], + ]) + ->add('email', EmailType::class, [ + 'label' => 'Email', + 'required' => true, + ]) + ->getForm() + ; + + $schema = $this->liform->transform($form); + + $this->assertArrayHasKey('properties', $schema); + $this->assertSame('string', $schema['properties']['username']['type']); + $this->assertArrayHasKey('attr', $schema['properties']['username']); + $this->assertSame(3, $schema['properties']['username']['attr']['minlength']); + $this->assertSame(20, $schema['properties']['username']['attr']['maxlength']); + } + + public function testChoiceFieldTransformation(): void + { + $form = $this->formFactory + ->createBuilder() + ->add('country', ChoiceType::class, [ + 'label' => 'Country', + 'choices' => [ + 'Germany' => 'de', + 'France' => 'fr', + 'Spain' => 'es', + ], + ]) + ->getForm() + ; + + $schema = $this->liform->transform($form); + + $this->assertArrayHasKey('properties', $schema); + $this->assertSame('string', $schema['properties']['country']['type']); + $this->assertArrayHasKey('enum', $schema['properties']['country']); + $this->assertContains('de', $schema['properties']['country']['enum']); + $this->assertContains('fr', $schema['properties']['country']['enum']); + $this->assertContains('es', $schema['properties']['country']['enum']); + } + + public function testMultipleChoiceFieldTransformation(): void + { + $form = $this->formFactory + ->createBuilder() + ->add('languages', ChoiceType::class, [ + 'label' => 'Languages', + 'multiple' => true, + 'choices' => [ + 'English' => 'en', + 'German' => 'de', + ], + ]) + ->getForm() + ; + + $schema = $this->liform->transform($form); + + $this->assertArrayHasKey('properties', $schema); + $this->assertSame('array', $schema['properties']['languages']['type']); + $this->assertArrayHasKey('items', $schema['properties']['languages']); + $this->assertSame('string', $schema['properties']['languages']['items']['type']); + } + + public function testRequiredFieldsInSchema(): void + { + $form = $this->formFactory + ->createBuilder() + ->add('required_field', TextType::class, [ + 'label' => 'Required Field', + 'required' => true, + ]) + ->add('optional_field', TextType::class, [ + 'label' => 'Optional Field', + 'required' => false, + ]) + ->getForm() + ; + + $schema = $this->liform->transform($form); + + $this->assertArrayHasKey('required', $schema); + $this->assertContains('required_field', $schema['required']); + $this->assertNotContains('optional_field', $schema['required']); + } + + public function testSchemaWithIntegerAttributes(): void + { + $form = $this->formFactory + ->createBuilder() + ->add('rating', IntegerType::class, [ + 'label' => 'Rating', + 'attr' => [ + 'min' => 1, + 'max' => 5, + ], + ]) + ->getForm() + ; + + $schema = $this->liform->transform($form); + + $this->assertArrayHasKey('properties', $schema); + $this->assertSame('integer', $schema['properties']['rating']['type']); + $this->assertArrayHasKey('attr', $schema['properties']['rating']); + $this->assertSame(1, $schema['properties']['rating']['attr']['min']); + $this->assertSame(5, $schema['properties']['rating']['attr']['max']); + } + + public function testCompleteFormSchemaStructure(): void + { + $form = $this->formFactory + ->createBuilder() + ->add('firstName', TextType::class, [ + 'label' => 'First Name', + 'required' => true, + ]) + ->add('lastName', TextType::class, [ + 'label' => 'Last Name', + 'required' => true, + ]) + ->add('email', EmailType::class, [ + 'label' => 'Email', + 'required' => true, + ]) + ->add('age', IntegerType::class, [ + 'label' => 'Age', + 'required' => false, + ]) + ->add('message', TextareaType::class, [ + 'label' => 'Message', + 'required' => true, + ]) + ->getForm() + ; + + $schema = $this->liform->transform($form); + + // Verify top-level structure + $this->assertSame('object', $schema['type']); + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('required', $schema); + + // Verify all fields are present + $this->assertCount(5, $schema['properties']); + $this->assertArrayHasKey('firstName', $schema['properties']); + $this->assertArrayHasKey('lastName', $schema['properties']); + $this->assertArrayHasKey('email', $schema['properties']); + $this->assertArrayHasKey('age', $schema['properties']); + $this->assertArrayHasKey('message', $schema['properties']); + + // Verify required fields + $this->assertContains('firstName', $schema['required']); + $this->assertContains('lastName', $schema['required']); + $this->assertContains('email', $schema['required']); + $this->assertContains('message', $schema['required']); + $this->assertNotContains('age', $schema['required']); + } +} diff --git a/tests/Integration/OutputHandlerChainTest.php b/tests/Integration/OutputHandlerChainTest.php new file mode 100644 index 0000000..9342826 --- /dev/null +++ b/tests/Integration/OutputHandlerChainTest.php @@ -0,0 +1,228 @@ +configRepository = $this->createMock(ConfigurationRepository::class); + $this->outputRepository = $this->createMock(OutputRepository::class); + $this->form = $this->createMock(FormInterface::class); + $this->form->method('getName')->willReturn('test_form'); + } + + public function testAllOutputHandlersSucceed(): void + { + $output1 = new OutputStub(); + $output2 = new OutputStub(); + $output3 = new OutputStub(); + + $this->configRepository + ->method('get') + ->willReturn([ + 'forms' => [ + 'test_form' => [ + 'outputs' => [ + 'email' => [ + 'type' => 'email', + 'options' => ['to' => 'test@example.com'], + ], + 'log' => [ + 'type' => 'log', + 'options' => [], + ], + 'http' => [ + 'type' => 'http', + 'options' => ['url' => 'https://example.com/webhook'], + ], + ], + ], + ], + ]) + ; + + $this->outputRepository + ->method('get') + ->willReturnMap([ + ['email', $output1], + ['log', $output2], + ['http', $output3], + ]) + ; + + $formService = $this->createPartialMock(FormService::class, ['outputs', 'getConfig']); + $formService->method('getConfig')->willReturn([ + 'outputs' => [ + 'email' => ['type' => 'email', 'options' => ['to' => 'test@example.com']], + 'log' => ['type' => 'log', 'options' => []], + 'http' => ['type' => 'http', 'options' => ['url' => 'https://example.com/webhook']], + ], + ]); + + // Simulate the handler chain + $response = new OutputResponse(); + + $output1->initialize('email', $this->form, ['to' => 'test@example.com']); + $output2->initialize('log', $this->form, []); + $output3->initialize('http', $this->form, ['url' => 'https://example.com/webhook']); + + $handlers = ['email' => $output1, 'log' => $output2, 'http' => $output3]; + + foreach ($handlers as $handler) { + $handler->setOutputHandlers($handlers); + $response = $handler->handle($response); + } + + $this->assertTrue($response->getOverallStatus()); + $this->assertCount(3, $response->getMessages()); + } + + public function testPartialFailureInChain(): void + { + $output1 = new OutputStub(); + $output2 = new OutputStub(); + $output3 = new OutputStub(); + + // Make the second handler fail + $output2->setShouldFail(true); + + $response = new OutputResponse(); + + $output1->initialize('email', $this->form, []); + $output2->initialize('log', $this->form, []); + $output3->initialize('http', $this->form, []); + + $handlers = ['email' => $output1, 'log' => $output2, 'http' => $output3]; + + foreach ($handlers as $handler) { + $handler->setOutputHandlers($handlers); + $response = $handler->handle($response); + } + + $this->assertFalse($response->getOverallStatus()); + $this->assertCount(3, $response->getMessages()); + $this->assertStringContainsString('failure', (string) $response->getMessages()[1]); + } + + public function testAllOutputHandlersFail(): void + { + $output1 = new OutputStub(); + $output2 = new OutputStub(); + $output3 = new OutputStub(); + + // Make all handlers fail + $output1->setShouldFail(true); + $output2->setShouldFail(true); + $output3->setShouldFail(true); + + $response = new OutputResponse(); + + $output1->initialize('email', $this->form, []); + $output2->initialize('log', $this->form, []); + $output3->initialize('http', $this->form, []); + + $handlers = ['email' => $output1, 'log' => $output2, 'http' => $output3]; + + foreach ($handlers as $handler) { + $handler->setOutputHandlers($handlers); + $response = $handler->handle($response); + } + + $this->assertFalse($response->getOverallStatus()); + $this->assertCount(3, $response->getMessages()); + } + + public function testSingleOutputHandler(): void + { + $output1 = new OutputStub(); + + $response = new OutputResponse(); + + $output1->initialize('email', $this->form, []); + + $handlers = ['email' => $output1]; + + foreach ($handlers as $handler) { + $handler->setOutputHandlers($handlers); + $response = $handler->handle($response); + } + + $this->assertTrue($response->getOverallStatus()); + $this->assertCount(1, $response->getMessages()); + } + + public function testOutputResponseStatusAggregation(): void + { + $output1 = new OutputStub(); + $output2 = new OutputStub(); + + $response = new OutputResponse(); + + // First handler succeeds + $output1->initialize('handler1', $this->form, []); + $response = $output1->handle($response); + $this->assertTrue($response->getOverallStatus()); + + // Second handler fails + $output2->setShouldFail(true); + $output2->initialize('handler2', $this->form, []); + $response = $output2->handle($response); + + // Overall status should be failure + $this->assertFalse($response->getOverallStatus()); + } + + public function testOutputHandlerDependencies(): void + { + $output1 = new OutputStub(); + $output2 = new OutputStub(); + $output3 = new OutputStub(); + + $response = new OutputResponse(); + + $output1->initialize('first', $this->form, []); + $output2->initialize('second', $this->form, []); + $output3->initialize('third', $this->form, []); + + $handlers = [ + 'first' => $output1, + 'second' => $output2, + 'third' => $output3, + ]; + + // Each handler can access other handlers via setOutputHandlers + foreach ($handlers as $handler) { + $handler->setOutputHandlers($handlers); + $response = $handler->handle($response); + } + + // Verify all handlers were executed + $this->assertCount(3, $response->getMessages()); + + // Verify handlers can access each other + $this->assertSame($handlers, $output1->getOutputHandlers()); + $this->assertSame($handlers, $output2->getOutputHandlers()); + $this->assertSame($handlers, $output3->getOutputHandlers()); + } +} diff --git a/tests/Support/ChoiceProviderStub.php b/tests/Support/ChoiceProviderStub.php new file mode 100644 index 0000000..d51cddb --- /dev/null +++ b/tests/Support/ChoiceProviderStub.php @@ -0,0 +1,25 @@ + 'Option 1', 'option2' => 'Option 2']; + } + + public function choiceLabel(mixed $choice, mixed $key, mixed $value): ?string + { + return $choice; + } + + public function choiceAttribute(mixed $choice, mixed $key, mixed $value): array + { + return []; + } +} diff --git a/tests/Support/Factories/ConfigurationFactory.php b/tests/Support/Factories/ConfigurationFactory.php new file mode 100644 index 0000000..0be04d9 --- /dev/null +++ b/tests/Support/Factories/ConfigurationFactory.php @@ -0,0 +1,168 @@ + [ + $name => [ + 'csrf' => true, + 'method' => 'POST', + 'translate' => [ + 'field_labels' => true, + 'inline_choices' => false, + ], + 'fields' => [ + 'name' => [ + 'type' => 'TextType', + 'options' => [ + 'label' => 'Name', + 'required' => true, + ], + 'constraints' => ['NotBlank'], + 'provider' => null, + ], + 'email' => [ + 'type' => 'EmailType', + 'options' => [ + 'label' => 'Email Address', + 'required' => true, + ], + 'constraints' => ['NotBlank', 'Email'], + 'provider' => null, + ], + ], + 'outputs' => [ + 'email_admin' => [ + 'type' => 'email', + 'options' => [ + 'to' => 'admin@example.com', + 'document' => 5, + ], + ], + ], + 'redirect_handler' => null, + 'input_handler' => null, + 'api_error_message_template' => null, + ], + ], + ]; + } + + /** + * Creates a form configuration with multiple outputs. + */ + public static function createMultipleOutputsConfig(string $name = 'multi_output_form'): array + { + return [ + 'forms' => [ + $name => [ + 'csrf' => true, + 'method' => 'POST', + 'translate' => [ + 'field_labels' => false, + 'inline_choices' => false, + ], + 'fields' => [ + 'message' => [ + 'type' => 'TextareaType', + 'options' => ['label' => 'Message'], + 'constraints' => ['NotBlank'], + 'provider' => null, + ], + ], + 'outputs' => [ + 'email' => [ + 'type' => 'email', + 'options' => [ + 'to' => 'admin@example.com', + 'document' => 5, + ], + ], + 'log' => [ + 'type' => 'log', + 'options' => [], + ], + 'http' => [ + 'type' => 'http', + 'options' => [ + 'url' => 'https://example.com/webhook', + ], + ], + ], + 'redirect_handler' => null, + 'input_handler' => null, + 'api_error_message_template' => null, + ], + ], + ]; + } + + /** + * Creates a contact form configuration for testing. + */ + public static function createContactFormConfig(): array + { + return [ + 'forms' => [ + 'contact' => [ + 'csrf' => false, + 'method' => 'POST', + 'translate' => [ + 'field_labels' => true, + 'inline_choices' => false, + ], + 'fields' => [ + 'name' => [ + 'type' => 'TextType', + 'options' => [ + 'label' => 'Name', + 'required' => true, + ], + 'constraints' => ['NotBlank'], + 'provider' => null, + ], + 'email' => [ + 'type' => 'EmailType', + 'options' => [ + 'label' => 'Email Address', + 'required' => true, + ], + 'constraints' => ['NotBlank', 'Email'], + 'provider' => null, + ], + 'message' => [ + 'type' => 'TextareaType', + 'options' => [ + 'label' => 'Message', + 'required' => false, + ], + 'constraints' => [], + 'provider' => null, + ], + ], + 'outputs' => [ + 'email_admin' => [ + 'type' => 'email', + 'options' => [ + 'to' => 'admin@example.com', + 'document' => 5, + ], + ], + ], + 'redirect_handler' => null, + 'input_handler' => null, + 'api_error_message_template' => null, + ], + ], + ]; + } +} diff --git a/tests/Support/InputHandlerStub.php b/tests/Support/InputHandlerStub.php new file mode 100644 index 0000000..2138c87 --- /dev/null +++ b/tests/Support/InputHandlerStub.php @@ -0,0 +1,22 @@ +key = $key; + } + + public function setOutputHandlers(array $handlers): void + { + $this->outputHandlers = $handlers; + } + + public function getOutputHandlers(): array + { + return $this->outputHandlers; + } + + public function setShouldFail(bool $shouldFail): void + { + $this->shouldFail = $shouldFail; + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + if ($this->shouldFail) { + $message = (new Message()) + ->setType(MessageConstants::MESSAGE_TYPE_ERROR) + ->setMessage($this->key . ' - failure') + ; + $outputResponse->addMessage($message); + $outputResponse->addStatus(false); + } else { + $message = (new Message()) + ->setType(MessageConstants::MESSAGE_TYPE_SUCCESS) + ->setMessage($this->key . ' - success') + ; + $outputResponse->addMessage($message); + $outputResponse->addStatus(true); + } + + return $outputResponse; + } +} diff --git a/tests/Support/RedirectHandlerStub.php b/tests/Support/RedirectHandlerStub.php new file mode 100644 index 0000000..538ee5a --- /dev/null +++ b/tests/Support/RedirectHandlerStub.php @@ -0,0 +1,20 @@ +addExtension(new ValidatorExtension($validator)) + ->getFormFactory() + ; + + return $formFactory->createNamedBuilder($name, FormType::class); + } + + /** + * Creates a form with submitted data. + */ + protected function createFormWithData(array $data, string $name = 'test_form'): FormInterface + { + $builder = $this->createFormBuilder($name); + + // Add fields based on data keys + foreach (array_keys($data) as $fieldName) { + $builder->add($fieldName, TextType::class); + } + + $form = $builder->getForm(); + $form->submit($data); + + return $form; + } + + /** + * Creates a valid submitted form. + */ + protected function createValidForm(array $data = ['name' => 'John Doe']): FormInterface + { + return $this->createFormWithData($data); + } +} diff --git a/tests/Support/Traits/MocksPimcoreAsset.php b/tests/Support/Traits/MocksPimcoreAsset.php new file mode 100644 index 0000000..8ac7257 --- /dev/null +++ b/tests/Support/Traits/MocksPimcoreAsset.php @@ -0,0 +1,37 @@ +createMock(Asset::class); + $asset->method('getId')->willReturn($id); + $asset->method('getFilename')->willReturn($filename); + $asset->method('save')->willReturn(true); + + return $asset; + } + + /** + * Creates a mock Pimcore Asset Folder. + */ + protected function createMockAssetFolder(string $path = '/uploads'): MockObject + { + $folder = $this->createMock(Asset\Folder::class); + $folder->method('getFullPath')->willReturn($path); + $folder->method('getId')->willReturn(100); + $folder->method('save')->willReturnSelf(); + + return $folder; + } +} diff --git a/tests/Support/Traits/MocksPimcoreDataObject.php b/tests/Support/Traits/MocksPimcoreDataObject.php new file mode 100644 index 0000000..5da79ef --- /dev/null +++ b/tests/Support/Traits/MocksPimcoreDataObject.php @@ -0,0 +1,37 @@ +createMock(Concrete::class); + $obj->method('getId')->willReturn($id); + $obj->method('save')->willReturn(true); + $obj->method('getClassName')->willReturn($class); + + return $obj; + } + + /** + * Creates a mock Pimcore DataObject Folder. + */ + protected function createMockDataObjectFolder(string $path = '/Forms'): MockObject + { + $folder = $this->createMock(Folder::class); + $folder->method('getFullPath')->willReturn($path); + $folder->method('getId')->willReturn(1); + + return $folder; + } +} diff --git a/tests/Support/Traits/MocksPimcoreDocument.php b/tests/Support/Traits/MocksPimcoreDocument.php new file mode 100644 index 0000000..e60f73c --- /dev/null +++ b/tests/Support/Traits/MocksPimcoreDocument.php @@ -0,0 +1,23 @@ +createMock(Document::class); + $doc->method('getId')->willReturn($id); + $doc->method('getFullPath')->willReturn($path); + + return $doc; + } +} diff --git a/tests/Support/Traits/MocksPimcoreMail.php b/tests/Support/Traits/MocksPimcoreMail.php new file mode 100644 index 0000000..c994775 --- /dev/null +++ b/tests/Support/Traits/MocksPimcoreMail.php @@ -0,0 +1,41 @@ +createMock(Mail::class); + $mail->method('addTo')->willReturnSelf(); + $mail->method('setDocument')->willReturnSelf(); + $mail->method('setParams')->willReturnSelf(); + $mail->method('subject')->willReturnSelf(); + $mail->method('addFrom')->willReturnSelf(); + $mail->method('send')->willReturnSelf(); + + return $mail; + } + + /** + * Creates a mock Pimcore Mail object that fails to send. + */ + protected function createFailingMockPimcoreMail(): MockObject + { + $mail = $this->createMock(Mail::class); + $mail->method('addTo')->willReturnSelf(); + $mail->method('setDocument')->willReturnSelf(); + $mail->method('setParams')->willReturnSelf(); + $mail->method('send')->willThrowException(new \Exception('Failed to send mail')); + + return $mail; + } +} diff --git a/tests/Unit/Controller/FormControllerTest.php b/tests/Unit/Controller/FormControllerTest.php new file mode 100644 index 0000000..3790032 --- /dev/null +++ b/tests/Unit/Controller/FormControllerTest.php @@ -0,0 +1,354 @@ +formService = $this->createMock(FormService::class); + $this->translator = $this->createMock(TranslatorInterface::class); + $this->form = $this->createMock(FormInterface::class); + $this->container = $this->createMock(ContainerInterface::class); + $this->twig = $this->createMock(Environment::class); + + // Setup container with Twig service + $this->container->method('has')->willReturnCallback(fn ($id) => $id === 'twig'); + $this->container->method('get')->willReturnCallback(fn ($id) => $id === 'twig' ? $this->twig : null); + + $this->controller = new FormController(); + $this->controller->setContainer($this->container); + } + + // uiAction tests + public function testUiActionReturnsResponseWithFormName(): void + { + $this->twig->method('render')->willReturn('
Vue App
'); + + $response = $this->controller->uiAction('contact_form'); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testUiActionUsesVueTwigTemplate(): void + { + $this->twig->expects($this->once()) + ->method('render') + ->with('@ValanticPimcoreForms/vue.html.twig', ['name' => 'test_form']) + ->willReturn('
Vue App
') + ; + + $response = $this->controller->uiAction('test_form'); + + $this->assertInstanceOf(Response::class, $response); + $this->assertTrue($response->isSuccessful()); + } + + // htmlAction tests + public function testHtmlActionBuildsFormAndReturnsResponse(): void + { + $formView = $this->createMock(FormView::class); + $this->form->method('createView')->willReturn($formView); + + $this->formService->expects($this->once()) + ->method('buildForm') + ->with('contact_form') + ->willReturn($this->form) + ; + + $this->twig->method('render')->willReturn('
'); + + $response = $this->controller->htmlAction('contact_form', $this->formService); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testHtmlActionUsesHtmlTwigTemplate(): void + { + $formView = $this->createMock(FormView::class); + $this->form->method('createView')->willReturn($formView); + + $this->formService->method('buildForm')->willReturn($this->form); + + $this->twig->expects($this->once()) + ->method('render') + ->with('@ValanticPimcoreForms/html.html.twig', $this->anything()) + ->willReturn('
') + ; + + $response = $this->controller->htmlAction('newsletter_form', $this->formService); + + $this->assertInstanceOf(Response::class, $response); + $this->assertTrue($response->isSuccessful()); + } + + // apiAction tests - GET (schema retrieval) + public function testApiActionGetReturnsJsonSchema(): void + { + $request = Request::create('/api/test_form', 'GET'); + + $this->form->method('isSubmitted')->willReturn(false); + $this->formService->method('buildForm')->willReturn($this->form); + $this->formService->method('buildJson')->willReturn(['type' => 'object', 'properties' => []]); + + $response = $this->controller->apiAction('test_form', $this->formService, $request, $this->translator); + + $this->assertInstanceOf(ApiResponse::class, $response); + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertArrayHasKey('data', $data); + $this->assertEquals('object', $data['data']['type']); + } + + // apiAction tests - POST (form submission with valid data) + public function testApiActionPostWithValidDataReturnsSuccess(): void + { + $formData = ['name' => 'John Doe', 'email' => 'john@example.com']; + $request = Request::create('/api/contact', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($formData)); + + $this->form->method('handleRequest')->willReturnSelf(); + $this->form->method('isSubmitted')->willReturn(true); + $this->form->method('isValid')->willReturn(true); + $this->form->method('getData')->willReturn($formData); + + $outputResponse = new OutputResponse(); + $outputResponse->addStatus(true); + + $this->formService->method('buildForm')->willReturn($this->form); + $this->formService->method('outputs')->willReturn($outputResponse); + $this->formService->method('getRedirectUrl')->willReturn(null); + + $this->translator->method('trans') + ->with('valantic.pimcoreForms.formSubmitSuccess') + ->willReturn('Form submitted successfully') + ; + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + $this->assertInstanceOf(ApiResponse::class, $response); + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertArrayHasKey('data', $data); + $this->assertArrayHasKey('messages', $data); + $this->assertCount(1, $data['messages']); + $this->assertEquals(MessageConstants::MESSAGE_TYPE_SUCCESS, $data['messages'][0]['type']); + } + + public function testApiActionPostWithValidDataAndRedirectUrl(): void + { + $formData = ['email' => 'test@example.com']; + $request = Request::create('/api/newsletter', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($formData)); + $redirectUrl = '/thank-you'; + + $this->form->method('handleRequest')->willReturnSelf(); + $this->form->method('isSubmitted')->willReturn(true); + $this->form->method('isValid')->willReturn(true); + $this->form->method('getData')->willReturn($formData); + + $outputResponse = new OutputResponse(); + $outputResponse->addStatus(true); + + $this->formService->method('buildForm')->willReturn($this->form); + $this->formService->method('outputs')->willReturn($outputResponse); + $this->formService->method('getRedirectUrl')->willReturn($redirectUrl); + + $this->translator->method('trans')->willReturn('Success'); + + $response = $this->controller->apiAction('newsletter', $this->formService, $request, $this->translator); + + $this->assertInstanceOf(ApiResponse::class, $response); + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertEquals($redirectUrl, $data['redirectUrl']); + } + + // apiAction tests - POST (validation errors) + public function testApiActionPostWithInvalidDataReturnsValidationErrors(): void + { + $formData = ['email' => 'invalid-email']; + $request = Request::create('/api/contact', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($formData)); + + $this->form->method('handleRequest')->willReturnSelf(); + $this->form->method('isSubmitted')->willReturn(true); + $this->form->method('isValid')->willReturn(false); + + $errors = [ + ['type' => MessageConstants::MESSAGE_TYPE_ERROR, 'message' => 'Invalid email format', 'field' => 'email'], + ]; + + $this->formService->method('buildForm')->willReturn($this->form); + $this->formService->method('errors')->willReturn($errors); + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + $this->assertInstanceOf(ApiResponse::class, $response); + $this->assertEquals(Response::HTTP_PRECONDITION_FAILED, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertArrayHasKey('messages', $data); + $this->assertCount(1, $data['messages']); + $this->assertEquals('Invalid email format', $data['messages'][0]['message']); + } + + // apiAction tests - Output handler failures + public function testApiActionPostWithOutputHandlerFailureReturnsError(): void + { + $formData = ['message' => 'Test']; + $request = Request::create('/api/contact', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($formData)); + + $this->form->method('handleRequest')->willReturnSelf(); + $this->form->method('isSubmitted')->willReturn(true); + $this->form->method('isValid')->willReturn(true); + $this->form->method('getData')->willReturn($formData); + + $outputResponse = new OutputResponse(); + $outputResponse->addStatus(false); // Output handler failed + + $this->formService->method('buildForm')->willReturn($this->form); + $this->formService->method('outputs')->willReturn($outputResponse); + $this->formService->method('getRedirectUrl')->willReturn(null); + + $this->translator->method('trans') + ->with('valantic.pimcoreForms.formSubmitError') + ->willReturn('Form submission failed') + ; + + $response = $this->controller->apiAction('contact', $this->formService, $request, $this->translator); + + $this->assertInstanceOf(ApiResponse::class, $response); + $this->assertEquals(Response::HTTP_PRECONDITION_FAILED, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertArrayHasKey('messages', $data); + $this->assertEquals(MessageConstants::MESSAGE_TYPE_ERROR, $data['messages'][0]['type']); + $this->assertEquals('Form submission failed', $data['messages'][0]['message']); + } + + public function testApiActionPostWithCustomOutputMessages(): void + { + $formData = ['data' => 'test']; + $request = Request::create('/api/form', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($formData)); + + $this->form->method('handleRequest')->willReturnSelf(); + $this->form->method('isSubmitted')->willReturn(true); + $this->form->method('isValid')->willReturn(true); + $this->form->method('getData')->willReturn($formData); + + $outputResponse = new OutputResponse(); + $outputResponse->addStatus(true); + $customMessage = (new Message()) + ->setType(MessageConstants::MESSAGE_TYPE_INFO) + ->setMessage('Email sent successfully') + ; + $outputResponse->addMessage($customMessage); + + $this->formService->method('buildForm')->willReturn($this->form); + $this->formService->method('outputs')->willReturn($outputResponse); + $this->formService->method('getRedirectUrl')->willReturn(null); + + $response = $this->controller->apiAction('form', $this->formService, $request, $this->translator); + + $this->assertInstanceOf(ApiResponse::class, $response); + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertCount(1, $data['messages']); + $this->assertEquals('Email sent successfully', $data['messages'][0]['message']); + } + + // apiAction tests - JSON parsing + public function testApiActionHandlesJsonRequestBodyParsing(): void + { + $formData = ['field' => 'value']; + $request = Request::create('/api/test', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($formData)); + + $this->form->expects($this->once()) + ->method('submit') + ->with($formData) + ; + $this->form->method('handleRequest')->willReturnSelf(); + $this->form->method('isSubmitted')->willReturn(false, true); + $this->form->method('isValid')->willReturn(true); + $this->form->method('getData')->willReturn($formData); + + $outputResponse = new OutputResponse(); + $outputResponse->addStatus(true); + + $this->formService->method('buildForm')->willReturn($this->form); + $this->formService->method('outputs')->willReturn($outputResponse); + $this->formService->method('getRedirectUrl')->willReturn(null); + $this->translator->method('trans')->willReturn('Success'); + + $response = $this->controller->apiAction('test', $this->formService, $request, $this->translator); + + $this->assertInstanceOf(ApiResponse::class, $response); + } + + public function testApiActionIgnoresNullJsonRequestBody(): void + { + // Test with 'null' as JSON body - this is valid JSON but should not submit the form + $request = Request::create('/api/test', 'GET', [], [], [], ['CONTENT_TYPE' => 'application/json'], 'null'); + + $this->form->expects($this->never())->method('submit'); + $this->form->method('handleRequest')->willReturnSelf(); + $this->form->method('isSubmitted')->willReturn(false); + + $this->formService->method('buildForm')->willReturn($this->form); + $this->formService->method('buildJson')->willReturn(['type' => 'object']); + + $response = $this->controller->apiAction('test', $this->formService, $request, $this->translator); + + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + } + + // mailDocumentAction test + public function testMailDocumentActionFiltersRequestAttributes(): void + { + $request = Request::create('/mail-document'); + $request->attributes->set('form_contents', '

Form Data

'); + $request->attributes->set('_route', 'mail_document'); + $request->attributes->set('_controller', 'FormController::mailDocumentAction'); + $request->attributes->set('custom_param', 'value'); + + $result = $this->controller->mailDocumentAction($request); + + $this->assertIsArray($result); + $this->assertArrayHasKey('form_contents', $result); + $this->assertArrayHasKey('custom_param', $result); + // Internal Symfony attributes starting with _ should be filtered out + $this->assertArrayNotHasKey('_route', $result); + $this->assertArrayNotHasKey('_controller', $result); + } +} diff --git a/tests/Unit/DependencyInjection/Compiler/ExtensionCompilerPassTest.php b/tests/Unit/DependencyInjection/Compiler/ExtensionCompilerPassTest.php new file mode 100644 index 0000000..c01fcab --- /dev/null +++ b/tests/Unit/DependencyInjection/Compiler/ExtensionCompilerPassTest.php @@ -0,0 +1,109 @@ +compilerPass = new ExtensionCompilerPass(); + $this->container = new ContainerBuilder(); + } + + public function testProcessWithNoLiformDefinition(): void + { + // Should not throw an exception when Liform is not defined + $this->compilerPass->process($this->container); + + $this->assertTrue(true); // If we get here, no exception was thrown + } + + public function testProcessWithExtensionService(): void + { + $liformDefinition = new Definition(Liform::class); + $this->container->setDefinition(Liform::class, $liformDefinition); + + $extensionDefinition = new Definition(MockExtension::class); + $extensionDefinition->addTag(ExtensionCompilerPass::EXTENSION_TAG); + $this->container->setDefinition('test.extension', $extensionDefinition); + + $this->compilerPass->process($this->container); + + $methodCalls = $liformDefinition->getMethodCalls(); + $this->assertCount(1, $methodCalls); + $this->assertSame('addExtension', $methodCalls[0][0]); + } + + public function testProcessWithMultipleExtensions(): void + { + $liformDefinition = new Definition(Liform::class); + $this->container->setDefinition(Liform::class, $liformDefinition); + + $extensionDefinition1 = new Definition(MockExtension::class); + $extensionDefinition1->addTag(ExtensionCompilerPass::EXTENSION_TAG); + $this->container->setDefinition('test.extension1', $extensionDefinition1); + + $extensionDefinition2 = new Definition(MockExtension::class); + $extensionDefinition2->addTag(ExtensionCompilerPass::EXTENSION_TAG); + $this->container->setDefinition('test.extension2', $extensionDefinition2); + + $this->compilerPass->process($this->container); + + $methodCalls = $liformDefinition->getMethodCalls(); + $this->assertCount(2, $methodCalls); + $this->assertSame('addExtension', $methodCalls[0][0]); + $this->assertSame('addExtension', $methodCalls[1][0]); + } + + public function testProcessThrowsExceptionWhenExtensionDoesNotImplementInterface(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('does not implement the mandatory'); + + $liformDefinition = new Definition(Liform::class); + $this->container->setDefinition(Liform::class, $liformDefinition); + + $extensionDefinition = new Definition(\stdClass::class); + $extensionDefinition->addTag(ExtensionCompilerPass::EXTENSION_TAG); + $this->container->setDefinition('test.extension', $extensionDefinition); + + $this->compilerPass->process($this->container); + } + + public function testProcessSkipsExtensionWithoutClass(): void + { + $liformDefinition = new Definition(Liform::class); + $this->container->setDefinition(Liform::class, $liformDefinition); + + $extensionDefinition = new Definition(); + $extensionDefinition->addTag(ExtensionCompilerPass::EXTENSION_TAG); + $this->container->setDefinition('test.extension', $extensionDefinition); + + $this->compilerPass->process($this->container); + + $methodCalls = $liformDefinition->getMethodCalls(); + $this->assertCount(0, $methodCalls); + } +} + +class MockExtension implements ExtensionInterface +{ + public function apply(\Symfony\Component\Form\FormInterface $form, array $schema) + { + return $schema; + } +} diff --git a/tests/Unit/DependencyInjection/Compiler/TransformerCompilerPassTest.php b/tests/Unit/DependencyInjection/Compiler/TransformerCompilerPassTest.php new file mode 100644 index 0000000..7b6f9c0 --- /dev/null +++ b/tests/Unit/DependencyInjection/Compiler/TransformerCompilerPassTest.php @@ -0,0 +1,110 @@ +compilerPass = new TransformerCompilerPass(); + $this->container = new ContainerBuilder(); + } + + public function testProcessWithNoResolverDefinition(): void + { + // Should not throw an exception when resolver is not defined + $this->compilerPass->process($this->container); + + $this->assertTrue(true); // If we get here, no exception was thrown + } + + public function testProcessWithTransformerService(): void + { + $resolverDefinition = new Definition(Resolver::class); + $this->container->setDefinition(Resolver::class, $resolverDefinition); + + $transformerDefinition = new Definition(MockTransformer::class); + $transformerDefinition->addTag(TransformerCompilerPass::TRANSFORMER_TAG, ['form_type' => 'TextType']); + $this->container->setDefinition('test.transformer', $transformerDefinition); + + $this->compilerPass->process($this->container); + + $methodCalls = $resolverDefinition->getMethodCalls(); + $this->assertCount(1, $methodCalls); + $this->assertSame('setTransformer', $methodCalls[0][0]); + $this->assertSame('TextType', $methodCalls[0][1][0]); + } + + public function testProcessWithTransformerServiceWithWidget(): void + { + $resolverDefinition = new Definition(Resolver::class); + $this->container->setDefinition(Resolver::class, $resolverDefinition); + + $transformerDefinition = new Definition(MockTransformer::class); + $transformerDefinition->addTag(TransformerCompilerPass::TRANSFORMER_TAG, [ + 'form_type' => 'ChoiceType', + 'widget' => 'select', + ]); + $this->container->setDefinition('test.transformer', $transformerDefinition); + + $this->compilerPass->process($this->container); + + $methodCalls = $resolverDefinition->getMethodCalls(); + $this->assertCount(1, $methodCalls); + $this->assertSame('setTransformer', $methodCalls[0][0]); + $this->assertSame('ChoiceType', $methodCalls[0][1][0]); + $this->assertSame('select', $methodCalls[0][1][2]); + } + + public function testProcessThrowsExceptionWhenTransformerDoesNotImplementInterface(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('does not implement the mandatory'); + + $resolverDefinition = new Definition(Resolver::class); + $this->container->setDefinition(Resolver::class, $resolverDefinition); + + $transformerDefinition = new Definition(\stdClass::class); + $transformerDefinition->addTag(TransformerCompilerPass::TRANSFORMER_TAG, ['form_type' => 'TextType']); + $this->container->setDefinition('test.transformer', $transformerDefinition); + + $this->compilerPass->process($this->container); + } + + public function testProcessThrowsExceptionWhenFormTypeNotSpecified(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('does not specify the mandatory \'form_type\' option'); + + $resolverDefinition = new Definition(Resolver::class); + $this->container->setDefinition(Resolver::class, $resolverDefinition); + + $transformerDefinition = new Definition(MockTransformer::class); + $transformerDefinition->addTag(TransformerCompilerPass::TRANSFORMER_TAG); + $this->container->setDefinition('test.transformer', $transformerDefinition); + + $this->compilerPass->process($this->container); + } +} + +class MockTransformer implements TransformerInterface +{ + public function transform(\Symfony\Component\Form\FormInterface $form, array $extensions = [], ?string $widget = null): array + { + return []; + } +} diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..90cf166 --- /dev/null +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,376 @@ +configuration = new Configuration(); + $this->processor = new Processor(); + } + + public function testValidCompleteConfiguration(): void + { + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'contact' => [ + 'csrf' => true, + 'method' => 'POST', + 'translate' => [ + 'field_labels' => true, + 'inline_choices' => true, + ], + 'api_error_message_template' => '(%2$s) %1$s', + 'redirect_handler' => RedirectHandlerStub::class, + 'input_handler' => InputHandlerStub::class, + 'outputs' => [ + [ + 'type' => 'email', + 'options' => ['to' => 'test@example.com'], + ], + ], + 'fields' => [ + [ + 'type' => 'TextType', + 'constraints' => ['NotBlank'], + 'options' => ['label' => 'Name'], + ], + ], + ], + ], + ], + ]; + + $processedConfig = $this->processor->processConfiguration($this->configuration, $config); + + $this->assertArrayHasKey('forms', $processedConfig); + $this->assertArrayHasKey('contact', $processedConfig['forms']); + $this->assertTrue($processedConfig['forms']['contact']['csrf']); + $this->assertSame('POST', $processedConfig['forms']['contact']['method']); + $this->assertTrue($processedConfig['forms']['contact']['translate']['field_labels']); + $this->assertTrue($processedConfig['forms']['contact']['translate']['inline_choices']); + $this->assertSame('(%2$s) %1$s', $processedConfig['forms']['contact']['api_error_message_template']); + $this->assertSame(RedirectHandlerStub::class, $processedConfig['forms']['contact']['redirect_handler']); + $this->assertSame(InputHandlerStub::class, $processedConfig['forms']['contact']['input_handler']); + } + + public function testDefaultValues(): void + { + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'simple' => [ + 'outputs' => [ + ['type' => 'log', 'options' => []], + ], + 'fields' => [ + ['type' => 'TextType'], + ], + ], + ], + ], + ]; + + $processedConfig = $this->processor->processConfiguration($this->configuration, $config); + + $form = $processedConfig['forms']['simple']; + $this->assertTrue($form['csrf'], 'CSRF should default to true'); + $this->assertSame('POST', $form['method'], 'Method should default to POST'); + $this->assertNull($form['api_error_message_template'], 'API error template should default to null'); + $this->assertNull($form['redirect_handler'], 'Redirect handler should default to null'); + $this->assertNull($form['input_handler'], 'Input handler should default to null'); + $this->assertFalse($form['translate']['field_labels'], 'Field labels translation should default to false'); + $this->assertFalse($form['translate']['inline_choices'], 'Inline choices translation should default to false'); + $this->assertSame([], $form['fields'][0]['constraints'], 'Constraints should default to empty array'); + $this->assertSame([], $form['fields'][0]['options'], 'Options should default to empty array'); + } + + public function testMethodValidation(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Must be GET or POST'); + + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'test' => [ + 'method' => 'PUT', + 'outputs' => [['type' => 'log']], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $config); + } + + public function testInvalidRedirectHandler(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid redirect handler class found'); + + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'test' => [ + 'redirect_handler' => \stdClass::class, + 'outputs' => [['type' => 'log']], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $config); + } + + public function testInvalidInputHandler(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid input handler class found'); + + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'test' => [ + 'input_handler' => \stdClass::class, + 'outputs' => [['type' => 'log']], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $config); + } + + public function testInvalidFieldType(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid type class found'); + + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'test' => [ + 'outputs' => [['type' => 'log']], + 'fields' => [['type' => 'NonExistentType']], + ], + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $config); + } + + public function testInvalidConstraint(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid constraint class found'); + + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'test' => [ + 'outputs' => [['type' => 'log']], + 'fields' => [ + [ + 'type' => 'TextType', + 'constraints' => ['NonExistentConstraint'], + ], + ], + ], + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $config); + } + + public function testInvalidChoiceProvider(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Provider class must exist and implement'); + + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'test' => [ + 'outputs' => [['type' => 'log']], + 'fields' => [ + [ + 'type' => 'ChoiceType', + 'provider' => \stdClass::class, + ], + ], + ], + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $config); + } + + public function testValidChoiceProvider(): void + { + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'test' => [ + 'outputs' => [['type' => 'log']], + 'fields' => [ + [ + 'type' => 'ChoiceType', + 'provider' => ChoiceProviderStub::class, + ], + ], + ], + ], + ], + ]; + + $processedConfig = $this->processor->processConfiguration($this->configuration, $config); + $this->assertSame( + ChoiceProviderStub::class, + $processedConfig['forms']['test']['fields'][0]['provider'], + ); + } + + public function testEmailOutputRequiresToEmail(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('missing/invalid configuration options'); + + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'test' => [ + 'outputs' => [ + ['type' => 'email', 'options' => []], + ], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $config); + } + + public function testHttpOutputRequiresUrl(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('missing/invalid configuration options'); + + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'test' => [ + 'outputs' => [ + ['type' => 'http', 'options' => []], + ], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $config); + } + + public function testDataObjectOutputRequiresClassAndPath(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('missing/invalid configuration options'); + + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'test' => [ + 'outputs' => [ + ['type' => 'data_object', 'options' => ['class' => 'MyClass']], + ], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $config); + } + + public function testAssetOutputRequiresFieldsAndPath(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('missing/invalid configuration options'); + + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'test' => [ + 'outputs' => [ + ['type' => 'asset', 'options' => ['path' => '/assets']], + ], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $config); + } + + public function testFieldsRequired(): void + { + $this->expectException(InvalidConfigurationException::class); + + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'test' => [ + 'outputs' => [['type' => 'log']], + 'fields' => [], + ], + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $config); + } + + public function testOutputsRequired(): void + { + $this->expectException(InvalidConfigurationException::class); + + $config = [ + 'valantic_pimcore_forms' => [ + 'forms' => [ + 'test' => [ + 'outputs' => [], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $config); + } +} diff --git a/tests/Unit/DependencyInjection/ValanticPimcoreFormsExtensionTest.php b/tests/Unit/DependencyInjection/ValanticPimcoreFormsExtensionTest.php new file mode 100644 index 0000000..0faade8 --- /dev/null +++ b/tests/Unit/DependencyInjection/ValanticPimcoreFormsExtensionTest.php @@ -0,0 +1,208 @@ +extension = new ValanticPimcoreFormsExtension(); + $this->container = new ContainerBuilder(); + } + + public function testLoadWithMinimalConfiguration(): void + { + $config = [ + [ + 'forms' => [ + 'test' => [ + 'outputs' => [['type' => 'log']], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->extension->load($config, $this->container); + + $this->assertTrue($this->container->hasParameter(ConfigurationRepository::CONTAINER_TAG)); + } + + public function testLoadSetsConfigurationParameter(): void + { + $config = [ + [ + 'forms' => [ + 'contact' => [ + 'csrf' => true, + 'method' => 'POST', + 'outputs' => [['type' => 'email', 'options' => ['to' => 'test@example.com']]], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->extension->load($config, $this->container); + + $parameter = $this->container->getParameter(ConfigurationRepository::CONTAINER_TAG); + $this->assertIsArray($parameter); + $this->assertArrayHasKey('forms', $parameter); + $this->assertArrayHasKey('contact', $parameter['forms']); + $this->assertTrue($parameter['forms']['contact']['csrf']); + $this->assertSame('POST', $parameter['forms']['contact']['method']); + } + + public function testLoadRegistersOutputInterfaceForAutoconfiguration(): void + { + $config = [ + [ + 'forms' => [ + 'test' => [ + 'outputs' => [['type' => 'log']], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->extension->load($config, $this->container); + + $autoconfiguredInstances = $this->container->getAutoconfiguredInstanceof(); + $this->assertArrayHasKey(OutputInterface::class, $autoconfiguredInstances); + $this->assertTrue($autoconfiguredInstances[OutputInterface::class]->hasTag(ValanticPimcoreFormsExtension::TAG_OUTPUT)); + } + + public function testLoadRegistersRedirectHandlerInterfaceForAutoconfiguration(): void + { + $config = [ + [ + 'forms' => [ + 'test' => [ + 'outputs' => [['type' => 'log']], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->extension->load($config, $this->container); + + $autoconfiguredInstances = $this->container->getAutoconfiguredInstanceof(); + $this->assertArrayHasKey(RedirectHandlerInterface::class, $autoconfiguredInstances); + $this->assertTrue($autoconfiguredInstances[RedirectHandlerInterface::class]->hasTag(ValanticPimcoreFormsExtension::TAG_REDIRECT_HANDLER)); + } + + public function testLoadRegistersInputHandlerInterfaceForAutoconfiguration(): void + { + $config = [ + [ + 'forms' => [ + 'test' => [ + 'outputs' => [['type' => 'log']], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->extension->load($config, $this->container); + + $autoconfiguredInstances = $this->container->getAutoconfiguredInstanceof(); + $this->assertArrayHasKey(InputHandlerInterface::class, $autoconfiguredInstances); + $this->assertTrue($autoconfiguredInstances[InputHandlerInterface::class]->hasTag(ValanticPimcoreFormsExtension::TAG_INPUT_HANDLER)); + } + + public function testLoadRegistersChoicesInterfaceForAutoconfiguration(): void + { + $config = [ + [ + 'forms' => [ + 'test' => [ + 'outputs' => [['type' => 'log']], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->extension->load($config, $this->container); + + $autoconfiguredInstances = $this->container->getAutoconfiguredInstanceof(); + $this->assertArrayHasKey(ChoicesInterface::class, $autoconfiguredInstances); + $this->assertTrue($autoconfiguredInstances[ChoicesInterface::class]->hasTag(ValanticPimcoreFormsExtension::TAG_CHOICES)); + } + + public function testLoadWithComplexConfiguration(): void + { + $config = [ + [ + 'forms' => [ + 'form1' => [ + 'csrf' => true, + 'outputs' => [['type' => 'log']], + 'fields' => [['type' => 'TextType']], + ], + 'form2' => [ + 'csrf' => false, + 'method' => 'GET', + 'outputs' => [['type' => 'email', 'options' => ['to' => 'test@example.com']]], + 'fields' => [['type' => 'EmailType']], + ], + ], + ], + ]; + + $this->extension->load($config, $this->container); + + $parameter = $this->container->getParameter(ConfigurationRepository::CONTAINER_TAG); + $this->assertIsArray($parameter); + $this->assertArrayHasKey('forms', $parameter); + $this->assertCount(2, $parameter['forms']); + $this->assertArrayHasKey('form1', $parameter['forms']); + $this->assertArrayHasKey('form2', $parameter['forms']); + $this->assertTrue($parameter['forms']['form1']['csrf']); + $this->assertFalse($parameter['forms']['form2']['csrf']); + $this->assertSame('GET', $parameter['forms']['form2']['method']); + } + + public function testLoadProcessesConfiguration(): void + { + $config = [ + [ + 'forms' => [ + 'test' => [ + 'outputs' => [['type' => 'log']], + 'fields' => [['type' => 'TextType']], + ], + ], + ], + ]; + + $this->extension->load($config, $this->container); + + $parameter = $this->container->getParameter(ConfigurationRepository::CONTAINER_TAG); + + // Verify defaults were applied during processing + $this->assertTrue($parameter['forms']['test']['csrf']); + $this->assertSame('POST', $parameter['forms']['test']['method']); + $this->assertFalse($parameter['forms']['test']['translate']['field_labels']); + $this->assertFalse($parameter['forms']['test']['translate']['inline_choices']); + } +} diff --git a/tests/Unit/Form/BuilderTest.php b/tests/Unit/Form/BuilderTest.php new file mode 100644 index 0000000..dd99dc6 --- /dev/null +++ b/tests/Unit/Form/BuilderTest.php @@ -0,0 +1,366 @@ +urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $this->translator = $this->createMock(TranslatorInterface::class); + $this->formFactory = $this->createMock(FormFactoryInterface::class); + $this->choicesRepository = $this->createMock(ChoicesRepository::class); + + $this->builder = new Builder( + $this->urlGenerator, + $this->translator, + $this->formFactory, + $this->choicesRepository, + ); + } + + public function testFormCreatesFormBuilder(): void + { + $config = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $config['forms']['test_form']; + + $mockBuilder = $this->createMock(FormBuilderInterface::class); + $mockBuilder->method('setMethod')->willReturnSelf(); + $mockBuilder->method('setAction')->willReturnSelf(); + + $this->formFactory + ->expects($this->once()) + ->method('createNamedBuilder') + ->with('test_form', FormType::class, null, ['csrf_protection' => true]) + ->willReturn($mockBuilder) + ; + + $this->urlGenerator + ->expects($this->once()) + ->method('generate') + ->with('valantic_pimcoreforms_form_api', ['name' => 'test_form']) + ->willReturn('/api/test_form') + ; + + $result = $this->builder->form('test_form', $formConfig); + + $this->assertInstanceOf(FormBuilderInterface::class, $result); + } + + public function testFormSetsCorrectMethod(): void + { + $config = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $config['forms']['test_form']; + $formConfig['method'] = 'GET'; + + $mockBuilder = $this->createMock(FormBuilderInterface::class); + $mockBuilder->expects($this->once())->method('setMethod')->with('GET')->willReturnSelf(); + $mockBuilder->method('setAction')->willReturnSelf(); + + $this->formFactory->method('createNamedBuilder')->willReturn($mockBuilder); + $this->urlGenerator->method('generate')->willReturn('/api/test_form'); + + $this->builder->form('test_form', $formConfig); + } + + public function testFieldWithBasicTextFieldReturnsCorrectTypeAndOptions(): void + { + $definition = [ + 'type' => 'TextType', + 'options' => [ + 'label' => 'Name', + 'required' => true, + ], + 'constraints' => ['NotBlank'], + 'provider' => null, + ]; + + $formConfig = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $formConfig['forms']['test_form']; + + $result = $this->builder->field('test_form', $definition, $formConfig); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertEquals(TextType::class, $result[0]); + $this->assertIsArray($result[1]); + $this->assertArrayHasKey('label', $result[1]); + $this->assertArrayHasKey('constraints', $result[1]); + } + + public function testFieldWithTranslationsTranslatesLabel(): void + { + $definition = [ + 'type' => 'EmailType', + 'options' => [ + 'label' => 'form.email_label', + 'required' => true, + ], + 'constraints' => [], + 'provider' => null, + ]; + + $formConfig = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $formConfig['forms']['test_form']; + $formConfig['translate']['field_labels'] = true; + + $this->translator + ->expects($this->once()) + ->method('trans') + ->with('form.email_label') + ->willReturn('Email Address') + ; + + $result = $this->builder->field('test_form', $definition, $formConfig); + + $this->assertEquals('Email Address', $result[1]['label']); + } + + public function testFieldWithDateTypeAddsSingleTextWidget(): void + { + $definition = [ + 'type' => 'DateType', + 'options' => [ + 'label' => 'Birth Date', + ], + 'constraints' => [], + 'provider' => null, + ]; + + $formConfig = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $formConfig['forms']['test_form']; + + $result = $this->builder->field('test_form', $definition, $formConfig); + + $this->assertEquals(DateType::class, $result[0]); + $this->assertEquals('single_text', $result[1]['widget']); + } + + public function testFieldWithTimeTypeAddsSingleTextWidget(): void + { + $definition = [ + 'type' => 'TimeType', + 'options' => [ + 'label' => 'Time', + ], + 'constraints' => [], + 'provider' => null, + ]; + + $formConfig = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $formConfig['forms']['test_form']; + + $result = $this->builder->field('test_form', $definition, $formConfig); + + $this->assertEquals(TimeType::class, $result[0]); + $this->assertEquals('single_text', $result[1]['widget']); + } + + public function testFieldWithChoiceTypeAndInlineChoicesTranslatesChoices(): void + { + $definition = [ + 'type' => 'ChoiceType', + 'options' => [ + 'label' => 'Country', + 'choices' => [ + 'country.us' => 'us', + 'country.uk' => 'uk', + ], + ], + 'constraints' => [], + 'provider' => null, + ]; + + $formConfig = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $formConfig['forms']['test_form']; + $formConfig['translate']['inline_choices'] = true; + + $this->translator + ->method('trans') + ->willReturnCallback(function ($key) { + return match ($key) { + 'country.us' => 'United States', + 'country.uk' => 'United Kingdom', + default => $key, + }; + }) + ; + + $result = $this->builder->field('test_form', $definition, $formConfig); + + $this->assertEquals(ChoiceType::class, $result[0]); + $this->assertArrayHasKey('choices', $result[1]); + $this->assertArrayHasKey('United States', $result[1]['choices']); + $this->assertArrayHasKey('United Kingdom', $result[1]['choices']); + } + + public function testFieldWithChoiceProviderUsesProviderForChoices(): void + { + $mockProvider = $this->createMock(ChoicesInterface::class); + $mockProvider->method('choices')->willReturn(['Option 1', 'Option 2']); + $mockProvider->method('choiceLabel')->willReturnCallback(fn ($choice) => $choice); + $mockProvider->method('choiceAttribute')->willReturn([]); + + $this->choicesRepository + ->expects($this->once()) + ->method('get') + ->with('TestChoiceProvider') + ->willReturn($mockProvider) + ; + + $definition = [ + 'type' => 'ChoiceType', + 'options' => [ + 'label' => 'Select Option', + ], + 'constraints' => [], + 'provider' => 'TestChoiceProvider', + ]; + + $formConfig = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $formConfig['forms']['test_form']; + + $result = $this->builder->field('test_form', $definition, $formConfig); + + $this->assertEquals(ChoiceType::class, $result[0]); + $this->assertArrayHasKey('choices', $result[1]); + $this->assertArrayHasKey('choice_value', $result[1]); + $this->assertArrayHasKey('choice_label', $result[1]); + $this->assertArrayHasKey('choice_attr', $result[1]); + } + + public function testFieldWithMultipleConstraintsBuildsAllConstraints(): void + { + $definition = [ + 'type' => 'EmailType', + 'options' => [ + 'label' => 'Email', + ], + 'constraints' => ['NotBlank', 'Email'], + 'provider' => null, + ]; + + $formConfig = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $formConfig['forms']['test_form']; + + $result = $this->builder->field('test_form', $definition, $formConfig); + + $this->assertArrayHasKey('constraints', $result[1]); + $this->assertIsArray($result[1]['constraints']); + $this->assertCount(2, $result[1]['constraints']); + $this->assertInstanceOf(NotBlank::class, $result[1]['constraints'][0]); + $this->assertInstanceOf(Email::class, $result[1]['constraints'][1]); + } + + public function testFieldWithConstraintOptionsBuildsConstraintWithOptions(): void + { + $definition = [ + 'type' => 'TextType', + 'options' => [ + 'label' => 'Username', + ], + 'constraints' => [ + ['NotBlank' => ['message' => 'Username is required']], + ], + 'provider' => null, + ]; + + $formConfig = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $formConfig['forms']['test_form']; + + $result = $this->builder->field('test_form', $definition, $formConfig); + + $this->assertArrayHasKey('constraints', $result[1]); + $this->assertCount(1, $result[1]['constraints']); + $this->assertInstanceOf(NotBlank::class, $result[1]['constraints'][0]); + } + + public function testFieldWithFullyQualifiedClassNameUsesClassName(): void + { + $definition = [ + 'type' => EmailType::class, + 'options' => [], + 'constraints' => [Email::class], + 'provider' => null, + ]; + + $formConfig = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $formConfig['forms']['test_form']; + + $result = $this->builder->field('test_form', $definition, $formConfig); + + $this->assertEquals(EmailType::class, $result[0]); + $this->assertInstanceOf(Email::class, $result[1]['constraints'][0]); + } + + public function testFieldWithNoConstraintsReturnsEmptyConstraints(): void + { + $definition = [ + 'type' => 'TextType', + 'options' => ['label' => 'Name'], + 'constraints' => [], + 'provider' => null, + ]; + + $formConfig = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $formConfig['forms']['test_form']; + + $result = $this->builder->field('test_form', $definition, $formConfig); + + $this->assertArrayNotHasKey('constraints', $result[1]); + } + + public function testFieldWithChoiceConstraintInheritsChoicesFromOptions(): void + { + $choices = ['Option 1' => 'opt1', 'Option 2' => 'opt2']; + + $definition = [ + 'type' => 'ChoiceType', + 'options' => [ + 'label' => 'Select', + 'choices' => $choices, + 'multiple' => false, + ], + 'constraints' => ['Choice'], + 'provider' => null, + ]; + + $formConfig = ConfigurationFactory::createValidFormConfig('test_form'); + $formConfig = $formConfig['forms']['test_form']; + + $result = $this->builder->field('test_form', $definition, $formConfig); + + $this->assertArrayHasKey('constraints', $result[1]); + $constraint = $result[1]['constraints'][0]; + $this->assertInstanceOf(\Symfony\Component\Validator\Constraints\Choice::class, $constraint); + } +} diff --git a/tests/Unit/Form/Extension/ChoiceTypeExtensionTest.php b/tests/Unit/Form/Extension/ChoiceTypeExtensionTest.php new file mode 100644 index 0000000..25549f5 --- /dev/null +++ b/tests/Unit/Form/Extension/ChoiceTypeExtensionTest.php @@ -0,0 +1,162 @@ +formFactory = Forms::createFormFactory(); + $this->extension = new ChoiceTypeExtension(); + } + + public function testApplyReturnsSchemaUnchangedForNonChoiceType(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('field', TextType::class) + ->getForm() + ; + + $field = $form->get('field'); + $schema = ['type' => 'string']; + + $result = $this->extension->apply($field, $schema); + + $this->assertEquals($schema, $result); + $this->assertArrayNotHasKey('options', $result); + } + + public function testApplyAddsChoicesToSchema(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('field', ChoiceType::class, [ + 'choices' => [ + 'Option 1' => 'opt1', + 'Option 2' => 'opt2', + ], + ]) + ->getForm() + ; + + $field = $form->get('field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('options', $result); + $this->assertArrayHasKey('choices', $result['options']); + $this->assertIsArray($result['options']['choices']); + } + + public function testApplyConvertsChoiceAttributesToCamelCase(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('field', ChoiceType::class, [ + 'choices' => ['Option 1' => 'opt1'], + 'choice_attr' => [ + 'Option 1' => [ + 'data-value' => 'test', + 'custom-attr' => 'value', + ], + ], + ]) + ->getForm() + ; + + $field = $form->get('field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('options', $result); + $this->assertArrayHasKey('choices', $result['options']); + + $choices = $result['options']['choices']; + $this->assertNotEmpty($choices); + + $firstChoice = reset($choices); + $this->assertInstanceOf(ChoiceView::class, $firstChoice); + + $this->assertArrayHasKey('dataValue', $firstChoice->attr); + $this->assertArrayHasKey('customAttr', $firstChoice->attr); + $this->assertEquals('test', $firstChoice->attr['dataValue']); + $this->assertEquals('value', $firstChoice->attr['customAttr']); + } + + public function testApplyHandlesChoicesWithoutAttributes(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('field', ChoiceType::class, [ + 'choices' => [ + 'Option 1' => 'opt1', + 'Option 2' => 'opt2', + 'Option 3' => 'opt3', + ], + ]) + ->getForm() + ; + + $field = $form->get('field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('options', $result); + $this->assertArrayHasKey('choices', $result['options']); + $this->assertCount(3, $result['options']['choices']); + + foreach ($result['options']['choices'] as $choice) { + $this->assertInstanceOf(ChoiceView::class, $choice); + } + } + + public function testApplyHandlesMultipleChoicesWithMixedAttributes(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('field', ChoiceType::class, [ + 'choices' => [ + 'First' => 'first', + 'Second' => 'second', + ], + 'choice_attr' => [ + 'First' => ['data-id' => '1'], + 'Second' => ['data-priority' => 'high'], + ], + ]) + ->getForm() + ; + + $field = $form->get('field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('options', $result); + $this->assertArrayHasKey('choices', $result['options']); + + $choices = $result['options']['choices']; + $this->assertCount(2, $choices); + + $firstChoice = reset($choices); + $this->assertArrayHasKey('dataId', $firstChoice->attr); + + $secondChoice = next($choices); + $this->assertArrayHasKey('dataPriority', $secondChoice->attr); + } +} diff --git a/tests/Unit/Form/Extension/FormAttributeExtensionTest.php b/tests/Unit/Form/Extension/FormAttributeExtensionTest.php new file mode 100644 index 0000000..32dc2f5 --- /dev/null +++ b/tests/Unit/Form/Extension/FormAttributeExtensionTest.php @@ -0,0 +1,105 @@ +extension = new FormAttributeExtension(); + } + + public function testApplyReturnsSchemaUnchangedWhenNoAttrKey(): void + { + $schema = ['type' => 'string']; + $form = $this->createMock(\Symfony\Component\Form\FormInterface::class); + + $result = $this->extension->apply($form, $schema); + + $this->assertEquals($schema, $result); + } + + public function testApplyConvertsDashedAttributesToCamelCase(): void + { + $schema = [ + 'attr' => [ + 'data-value' => 'test', + 'custom-attribute' => 'value', + ], + ]; + $form = $this->createMock(\Symfony\Component\Form\FormInterface::class); + + $result = $this->extension->apply($form, $schema); + + $this->assertArrayHasKey('attr', $result); + $this->assertArrayHasKey('dataValue', $result['attr']); + $this->assertArrayHasKey('customAttribute', $result['attr']); + $this->assertEquals('test', $result['attr']['dataValue']); + $this->assertEquals('value', $result['attr']['customAttribute']); + $this->assertArrayNotHasKey('data-value', $result['attr']); + $this->assertArrayNotHasKey('custom-attribute', $result['attr']); + } + + public function testApplyHandlesSingleDashInAttribute(): void + { + $schema = [ + 'attr' => [ + 'aria-label' => 'Label', + 'data-id' => '123', + ], + ]; + $form = $this->createMock(\Symfony\Component\Form\FormInterface::class); + + $result = $this->extension->apply($form, $schema); + + $this->assertArrayHasKey('ariaLabel', $result['attr']); + $this->assertArrayHasKey('dataId', $result['attr']); + $this->assertEquals('Label', $result['attr']['ariaLabel']); + $this->assertEquals('123', $result['attr']['dataId']); + } + + public function testApplyHandlesMultipleDashesInAttribute(): void + { + $schema = [ + 'attr' => [ + 'data-some-long-attribute' => 'value', + ], + ]; + $form = $this->createMock(\Symfony\Component\Form\FormInterface::class); + + $result = $this->extension->apply($form, $schema); + + $this->assertArrayHasKey('dataSomeLongAttribute', $result['attr']); + $this->assertEquals('value', $result['attr']['dataSomeLongAttribute']); + } + + public function testApplyPreservesAttributesWithoutDashes(): void + { + $schema = [ + 'attr' => [ + 'class' => 'form-control', + 'id' => 'my-field', + 'data-value' => 'test', + ], + ]; + $form = $this->createMock(\Symfony\Component\Form\FormInterface::class); + + $result = $this->extension->apply($form, $schema); + + $this->assertArrayHasKey('class', $result['attr']); + $this->assertArrayHasKey('id', $result['attr']); + $this->assertArrayHasKey('dataValue', $result['attr']); + $this->assertEquals('form-control', $result['attr']['class']); + $this->assertEquals('my-field', $result['attr']['id']); + $this->assertEquals('test', $result['attr']['dataValue']); + } +} diff --git a/tests/Unit/Form/Extension/FormConstraintExtensionTest.php b/tests/Unit/Form/Extension/FormConstraintExtensionTest.php new file mode 100644 index 0000000..9bd3e45 --- /dev/null +++ b/tests/Unit/Form/Extension/FormConstraintExtensionTest.php @@ -0,0 +1,150 @@ +extension = new FormConstraintExtension(); + } + + public function testApplyReturnsSchemaUnchangedWhenNoConstraints(): void + { + $formConfig = $this->createMock(FormConfigInterface::class); + $formConfig->method('getOption')->with('constraints')->willReturn(null); + + $form = $this->createMock(FormInterface::class); + $form->method('getConfig')->willReturn($formConfig); + + $schema = ['type' => 'string']; + + $result = $this->extension->apply($form, $schema); + + $this->assertEquals($schema, $result); + $this->assertArrayNotHasKey('constraints', $result); + } + + public function testApplyAddsConstraintsToSchema(): void + { + $constraints = [new NotBlank()]; + + $formConfig = $this->createMock(FormConfigInterface::class); + $formConfig->method('getOption')->with('constraints')->willReturn($constraints); + + $form = $this->createMock(FormInterface::class); + $form->method('getConfig')->willReturn($formConfig); + + $schema = []; + + $result = $this->extension->apply($form, $schema); + + $this->assertArrayHasKey('constraints', $result); + $this->assertIsArray($result['constraints']); + $this->assertCount(1, $result['constraints']); + $this->assertEquals('NotBlank', $result['constraints'][0]['type']); + } + + public function testApplyHandlesMultipleConstraints(): void + { + $constraints = [ + new NotBlank(), + new Length(min: 5, max: 100), + new Email(), + ]; + + $formConfig = $this->createMock(FormConfigInterface::class); + $formConfig->method('getOption')->with('constraints')->willReturn($constraints); + + $form = $this->createMock(FormInterface::class); + $form->method('getConfig')->willReturn($formConfig); + + $schema = []; + + $result = $this->extension->apply($form, $schema); + + $this->assertArrayHasKey('constraints', $result); + $this->assertCount(3, $result['constraints']); + $this->assertEquals('NotBlank', $result['constraints'][0]['type']); + $this->assertEquals('Length', $result['constraints'][1]['type']); + $this->assertEquals('Email', $result['constraints'][2]['type']); + } + + public function testApplyIncludesConstraintConfiguration(): void + { + $constraints = [new Length(min: 5, max: 100)]; + + $formConfig = $this->createMock(FormConfigInterface::class); + $formConfig->method('getOption')->with('constraints')->willReturn($constraints); + + $form = $this->createMock(FormInterface::class); + $form->method('getConfig')->willReturn($formConfig); + + $schema = []; + + $result = $this->extension->apply($form, $schema); + + $this->assertArrayHasKey('constraints', $result); + $this->assertArrayHasKey('config', $result['constraints'][0]); + $this->assertArrayHasKey('min', $result['constraints'][0]['config']); + $this->assertArrayHasKey('max', $result['constraints'][0]['config']); + $this->assertEquals(5, $result['constraints'][0]['config']['min']); + $this->assertEquals(100, $result['constraints'][0]['config']['max']); + } + + public function testApplyAddsHtmlPatternForRegexConstraint(): void + { + $constraints = [new Regex(pattern: '/^[A-Z]/')]; + + $formConfig = $this->createMock(FormConfigInterface::class); + $formConfig->method('getOption')->with('constraints')->willReturn($constraints); + + $form = $this->createMock(FormInterface::class); + $form->method('getConfig')->willReturn($formConfig); + + $schema = []; + + $result = $this->extension->apply($form, $schema); + + $this->assertArrayHasKey('constraints', $result); + $this->assertEquals('Regex', $result['constraints'][0]['type']); + $this->assertArrayHasKey('htmlPattern', $result['constraints'][0]['config']); + $this->assertNotEmpty($result['constraints'][0]['config']['htmlPattern']); + } + + public function testApplyDoesNotOverwriteExistingHtmlPattern(): void + { + $customHtmlPattern = '^[A-Z]$'; + $constraints = [new Regex(pattern: '/^[A-Z]/', htmlPattern: $customHtmlPattern)]; + + $formConfig = $this->createMock(FormConfigInterface::class); + $formConfig->method('getOption')->with('constraints')->willReturn($constraints); + + $form = $this->createMock(FormInterface::class); + $form->method('getConfig')->willReturn($formConfig); + + $schema = []; + + $result = $this->extension->apply($form, $schema); + + $this->assertArrayHasKey('constraints', $result); + $this->assertEquals('Regex', $result['constraints'][0]['type']); + $this->assertArrayHasKey('htmlPattern', $result['constraints'][0]['config']); + $this->assertEquals($customHtmlPattern, $result['constraints'][0]['config']['htmlPattern']); + } +} diff --git a/tests/Unit/Form/Extension/FormDataExtensionTest.php b/tests/Unit/Form/Extension/FormDataExtensionTest.php new file mode 100644 index 0000000..cf41816 --- /dev/null +++ b/tests/Unit/Form/Extension/FormDataExtensionTest.php @@ -0,0 +1,109 @@ +formFactory = Forms::createFormFactory(); + $this->extension = new FormDataExtension(); + } + + public function testApplyAddsNullDataWhenNoDataSet(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('field', TextType::class) + ->getForm() + ; + + $field = $form->get('field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('data', $result); + $this->assertNull($result['data']); + } + + public function testApplyAddsDataFromForm(): void + { + $form = $this->formFactory->createBuilder(FormType::class, [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]) + ->add('name', TextType::class) + ->add('email', TextType::class) + ->getForm() + ; + + $nameField = $form->get('name'); + $emailField = $form->get('email'); + + $nameSchema = []; + $emailSchema = []; + + $nameResult = $this->extension->apply($nameField, $nameSchema); + $emailResult = $this->extension->apply($emailField, $emailSchema); + + $this->assertArrayHasKey('data', $nameResult); + $this->assertArrayHasKey('data', $emailResult); + $this->assertEquals('John Doe', $nameResult['data']); + $this->assertEquals('john@example.com', $emailResult['data']); + } + + public function testApplyAddsDataForFormWithObject(): void + { + $data = new \stdClass(); + $data->field = 'test value'; + + $form = $this->formFactory->createBuilder(FormType::class, $data) + ->add('field', TextType::class, ['property_path' => 'field']) + ->getForm() + ; + + $field = $form->get('field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('data', $result); + $this->assertEquals('test value', $result['data']); + } + + public function testApplyPreservesExistingSchemaKeys(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('field', TextType::class) + ->getForm() + ; + + $field = $form->get('field'); + $schema = [ + 'type' => 'string', + 'label' => 'Field Label', + ]; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('type', $result); + $this->assertArrayHasKey('label', $result); + $this->assertEquals('string', $result['type']); + $this->assertEquals('Field Label', $result['label']); + } +} diff --git a/tests/Unit/Form/Extension/FormNameExtensionTest.php b/tests/Unit/Form/Extension/FormNameExtensionTest.php new file mode 100644 index 0000000..f7987ce --- /dev/null +++ b/tests/Unit/Form/Extension/FormNameExtensionTest.php @@ -0,0 +1,70 @@ +formFactory = Forms::createFormFactory(); + $this->extension = new FormNameExtension(); + } + + public function testApplyAddsFormNameToSchema(): void + { + $form = $this->formFactory->createBuilder(FormType::class, null, ['action' => '/submit']) + ->add('field', TextType::class) + ->getForm() + ; + + $field = $form->get('field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('name', $result); + $this->assertEquals('field', $result['name']); + } + + public function testApplyAddsSubmitUrlForFormType(): void + { + $form = $this->formFactory->createBuilder(FormType::class, null, ['action' => '/submit'])->getForm(); + $schema = []; + + $result = $this->extension->apply($form, $schema); + + $this->assertArrayHasKey('name', $result); + $this->assertArrayHasKey('submitUrl', $result); + $this->assertEquals('/submit', $result['submitUrl']); + } + + public function testApplyDoesNotAddSubmitUrlForNonFormType(): void + { + $form = $this->formFactory->createBuilder(FormType::class, null, ['action' => '/submit']) + ->add('field', TextType::class) + ->getForm() + ; + + $field = $form->get('field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('name', $result); + $this->assertArrayNotHasKey('submitUrl', $result); + } +} diff --git a/tests/Unit/Form/Extension/FormTypeExtensionTest.php b/tests/Unit/Form/Extension/FormTypeExtensionTest.php new file mode 100644 index 0000000..32d2a6d --- /dev/null +++ b/tests/Unit/Form/Extension/FormTypeExtensionTest.php @@ -0,0 +1,168 @@ +formFactory = Forms::createFormFactory(); + $this->extension = new FormTypeExtension(); + } + + public function testApplyReturnsSchemaUnchangedForFormType(): void + { + $form = $this->formFactory->createBuilder(FormType::class)->getForm(); + $schema = ['type' => 'object']; + + $result = $this->extension->apply($form, $schema); + + $this->assertEquals($schema, $result); + $this->assertArrayNotHasKey('formType', $result); + } + + public function testApplyAddsFormTypeForTextType(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('field', TextType::class) + ->getForm() + ; + + $field = $form->get('field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('formType', $result); + $this->assertEquals('text', $result['formType']); + } + + public function testApplyMapsChoiceTypeToSelectSingle(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('field', ChoiceType::class, [ + 'choices' => ['Option 1' => 'opt1', 'Option 2' => 'opt2'], + 'expanded' => false, + 'multiple' => false, + ]) + ->getForm() + ; + + $field = $form->get('field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('formType', $result); + $this->assertEquals('select.single', $result['formType']); + } + + public function testApplyMapsChoiceTypeToSelectMultiple(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('field', ChoiceType::class, [ + 'choices' => ['Option 1' => 'opt1', 'Option 2' => 'opt2'], + 'expanded' => false, + 'multiple' => true, + ]) + ->getForm() + ; + + $field = $form->get('field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('formType', $result); + $this->assertEquals('select.multiple', $result['formType']); + } + + public function testApplyMapsChoiceTypeToRadio(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('field', ChoiceType::class, [ + 'choices' => ['Option 1' => 'opt1', 'Option 2' => 'opt2'], + 'expanded' => true, + 'multiple' => false, + ]) + ->getForm() + ; + + $field = $form->get('field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('formType', $result); + $this->assertEquals('radio', $result['formType']); + } + + public function testApplyMapsChoiceTypeToCheckboxes(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('field', ChoiceType::class, [ + 'choices' => ['Option 1' => 'opt1', 'Option 2' => 'opt2'], + 'expanded' => true, + 'multiple' => true, + ]) + ->getForm() + ; + + $field = $form->get('field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('formType', $result); + $this->assertEquals('checkboxes', $result['formType']); + } + + public function testApplyMapsStandardFormTypes(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('email', EmailType::class) + ->getForm() + ; + + $field = $form->get('email'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('formType', $result); + $this->assertEquals('email', $result['formType']); + } + + public function testApplyMapsCustomSubheaderType(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('subheader', SubheaderType::class) + ->getForm() + ; + + $field = $form->get('subheader'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('formType', $result); + $this->assertEquals('subheader', $result['formType']); + } +} diff --git a/tests/Unit/Form/Extension/HiddenTypeExtensionTest.php b/tests/Unit/Form/Extension/HiddenTypeExtensionTest.php new file mode 100644 index 0000000..f5582c0 --- /dev/null +++ b/tests/Unit/Form/Extension/HiddenTypeExtensionTest.php @@ -0,0 +1,77 @@ +formFactory = Forms::createFormFactory(); + $this->extension = new HiddenTypeExtension(); + } + + public function testApplyReturnsSchemaUnchangedForNonHiddenType(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('field', TextType::class) + ->getForm() + ; + + $field = $form->get('field'); + $schema = ['type' => 'string']; + + $result = $this->extension->apply($field, $schema); + + $this->assertEquals($schema, $result); + $this->assertArrayNotHasKey('value', $result); + } + + public function testApplyAddsValueForHiddenType(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('hidden_field', HiddenType::class, [ + 'data' => 'secret_value', + ]) + ->getForm() + ; + + $field = $form->get('hidden_field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('value', $result); + $this->assertEquals('secret_value', $result['value']); + } + + public function testApplyHandlesNullValueForHiddenType(): void + { + $form = $this->formFactory->createBuilder(FormType::class) + ->add('hidden_field', HiddenType::class) + ->getForm() + ; + + $field = $form->get('hidden_field'); + $schema = []; + + $result = $this->extension->apply($field, $schema); + + $this->assertArrayHasKey('value', $result); + $this->assertNull($result['value']); + } +} diff --git a/tests/Unit/Form/Output/AssetOutputTest.php b/tests/Unit/Form/Output/AssetOutputTest.php new file mode 100644 index 0000000..6ace81e --- /dev/null +++ b/tests/Unit/Form/Output/AssetOutputTest.php @@ -0,0 +1,252 @@ +assertEquals('asset', AssetOutput::name()); + } + + public function testHandleCreatesAssetFolderSuccessfully(): void + { + $uploadedFile = $this->createMock(UploadedFile::class); + $uploadedFile->method('getRealPath')->willReturn('/tmp/test.pdf'); + $uploadedFile->method('getClientOriginalName')->willReturn('document.pdf'); + $uploadedFile->method('guessExtension')->willReturn('pdf'); + + $fileField = $this->createMock(FormInterface::class); + $fileField->method('getData')->willReturn($uploadedFile); + + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('upload_form'); + $form->method('get')->with('attachment')->willReturn($fileField); + + $config = [ + 'path' => '/uploads', + 'fields' => ['attachment'], + 'createHashedFolder' => false, + ]; + + $parentFolder = $this->createMockAssetFolder('/uploads'); + + $capturedSubfolderName = null; + $capturedFiles = null; + + $output = new class($parentFolder, $capturedSubfolderName, $capturedFiles) extends AssetOutput { + public function __construct( + private Folder $mockParent, + private &$capturedSubfolderName, + private &$capturedFiles, + ) { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + $this->capturedSubfolderName = $this->getSubfolderName(); + $this->capturedFiles = $this->getFiles(); + + return $outputResponse->addStatus(true); + } + }; + + $output->initialize('asset_upload', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertTrue($result->getOverallStatus()); + $this->assertMatchesRegularExpression('/^upload_form_\d{8}-\d{6}$/', $capturedSubfolderName); + $this->assertArrayHasKey('attachment', $capturedFiles); + $this->assertInstanceOf(UploadedFile::class, $capturedFiles['attachment']); + } + + public function testHandleWithHashedFolderCreatesUuidSuffix(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('secure_upload'); + + $config = [ + 'path' => '/uploads', + 'fields' => ['file'], + 'createHashedFolder' => true, + ]; + + $capturedSubfolderName = null; + + $output = new class($capturedSubfolderName) extends AssetOutput { + public function __construct(private &$capturedSubfolderName) + { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + $this->capturedSubfolderName = $this->getSubfolderName(); + + return $outputResponse->addStatus(true); + } + }; + + $output->initialize('asset_secure', $form, $config); + + $response = new OutputResponse(); + $output->handle($response); + + // Should match: secure_upload_20240115-143022_uuid-here + $this->assertMatchesRegularExpression('/^secure_upload_\d{8}-\d{6}_[a-f0-9-]{36}$/', $capturedSubfolderName); + } + + public function testHandleWithMultipleFilesSavesAllFiles(): void + { + $file1 = $this->createMock(UploadedFile::class); + $file1->method('getRealPath')->willReturn('/tmp/file1.pdf'); + $file1->method('getClientOriginalName')->willReturn('document1.pdf'); + $file1->method('guessExtension')->willReturn('pdf'); + + $file2 = $this->createMock(UploadedFile::class); + $file2->method('getRealPath')->willReturn('/tmp/file2.jpg'); + $file2->method('getClientOriginalName')->willReturn('image2.jpg'); + $file2->method('guessExtension')->willReturn('jpg'); + + $field1 = $this->createMock(FormInterface::class); + $field1->method('getData')->willReturn($file1); + + $field2 = $this->createMock(FormInterface::class); + $field2->method('getData')->willReturn($file2); + + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('multi_upload'); + $form->method('get')->willReturnMap([ + ['document', $field1], + ['photo', $field2], + ]); + + $config = [ + 'path' => '/uploads', + 'fields' => ['document', 'photo'], + ]; + + $capturedFiles = null; + + $output = new class($capturedFiles) extends AssetOutput { + public function __construct(private &$capturedFiles) + { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + $this->capturedFiles = $this->getFiles(); + + return $outputResponse->addStatus(true); + } + }; + + $output->initialize('asset_multi', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertTrue($result->getOverallStatus()); + $this->assertCount(2, $capturedFiles); + $this->assertArrayHasKey('document', $capturedFiles); + $this->assertArrayHasKey('photo', $capturedFiles); + } + + public function testHandleWithInvalidPathThrowsException(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('test_form'); + + $config = [ + 'path' => '/nonexistent/path', + 'fields' => ['file'], + ]; + + $output = new class extends AssetOutput { + public function handle(OutputResponse $outputResponse): OutputResponse + { + throw new \InvalidArgumentException('Path /nonexistent/path not found'); + } + }; + + $output->initialize('asset_invalid', $form, $config); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Path /nonexistent/path not found'); + + $response = new OutputResponse(); + $output->handle($response); + } + + public function testGetFilesSkipsNonUploadedFileFields(): void + { + $uploadedFile = $this->createMock(UploadedFile::class); + $uploadedFile->method('getRealPath')->willReturn('/tmp/test.pdf'); + + $fileField = $this->createMock(FormInterface::class); + $fileField->method('getData')->willReturn($uploadedFile); + + $textField = $this->createMock(FormInterface::class); + $textField->method('getData')->willReturn('just a string'); + + $form = $this->createMock(FormInterface::class); + $form->method('get')->willReturnMap([ + ['attachment', $fileField], + ['name', $textField], + ]); + + $config = [ + 'path' => '/uploads', + 'fields' => ['attachment', 'name'], + ]; + + $output = new AssetOutput(); + $output->initialize('asset_filter', $form, $config); + + $reflection = new \ReflectionClass($output); + $method = $reflection->getMethod('getFiles'); + $method->setAccessible(true); + + $result = $method->invoke($output); + + // Only the uploaded file should be included + $this->assertCount(1, $result); + $this->assertArrayHasKey('attachment', $result); + $this->assertArrayNotHasKey('name', $result); + } + + public function testGetPathReturnsConfiguredPath(): void + { + $form = $this->createMock(FormInterface::class); + $config = [ + 'path' => '/custom/uploads/path', + 'fields' => ['file'], + ]; + + $output = new AssetOutput(); + $output->initialize('test', $form, $config); + + $reflection = new \ReflectionClass($output); + $method = $reflection->getMethod('getPath'); + $method->setAccessible(true); + + $result = $method->invoke($output); + + $this->assertEquals('/custom/uploads/path', $result); + } +} diff --git a/tests/Unit/Form/Output/DataObjectOutputTest.php b/tests/Unit/Form/Output/DataObjectOutputTest.php new file mode 100644 index 0000000..fd325bb --- /dev/null +++ b/tests/Unit/Form/Output/DataObjectOutputTest.php @@ -0,0 +1,204 @@ +assertEquals('data_object', DataObjectOutput::name()); + } + + public function testHandleCreatesDataObjectSuccessfully(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('contact_form'); + $form->method('getData')->willReturn([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + ]); + + $config = [ + 'class' => 'FormSubmission', + 'path' => '/form-submissions', + ]; + + $capturedData = null; + $capturedPath = null; + $capturedKey = null; + + $output = new class($capturedData, $capturedPath, $capturedKey) extends DataObjectOutput { + public function __construct( + private &$capturedData, + private &$capturedPath, + private &$capturedKey, + ) { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + // Simulate data object creation without actually creating it + $this->capturedData = $this->getData(); + $this->capturedPath = $this->getPath(); + $this->capturedKey = $this->getKey(); + + return $outputResponse->addStatus(true); + } + }; + + $output->initialize('dataobject_submission', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertTrue($result->getOverallStatus()); + $this->assertEquals([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + ], $capturedData); + $this->assertEquals('/form-submissions', $capturedPath); + $this->assertMatchesRegularExpression('/^contact_form-\d+$/', $capturedKey); + } + + public function testHandleWithInvalidClassThrowsException(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getData')->willReturn(['test' => 'data']); + + $config = [ + 'class' => 'NonExistentClass', + 'path' => '/submissions', + ]; + + $output = new class extends DataObjectOutput { + public function handle(OutputResponse $outputResponse): OutputResponse + { + throw new \InvalidArgumentException('DataObject Pimcore\Model\DataObject\NonExistentClass does not exist'); + } + }; + + $output->initialize('dataobject_invalid', $form, $config); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('DataObject Pimcore\Model\DataObject\NonExistentClass does not exist'); + + $response = new OutputResponse(); + $output->handle($response); + } + + public function testHandleWithInvalidPathThrowsException(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getData')->willReturn(['test' => 'data']); + + $config = [ + 'class' => 'FormSubmission', + 'path' => '/nonexistent-path', + ]; + + $output = new class extends DataObjectOutput { + public function handle(OutputResponse $outputResponse): OutputResponse + { + throw new \InvalidArgumentException('Path /nonexistent-path not found'); + } + }; + + $output->initialize('dataobject_badpath', $form, $config); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Path /nonexistent-path not found'); + + $response = new OutputResponse(); + $output->handle($response); + } + + public function testGetKeyGeneratesUniqueKeyWithTimestamp(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('newsletter_signup'); + $form->method('getData')->willReturn(['email' => 'test@example.com']); + + $config = [ + 'class' => 'FormSubmission', + 'path' => '/submissions', + ]; + + $output = new DataObjectOutput(); + $output->initialize('dataobject_key', $form, $config); + + // Use reflection to access protected method + $reflection = new \ReflectionClass($output); + $method = $reflection->getMethod('getKey'); + $method->setAccessible(true); + + $key1 = $method->invoke($output); + sleep(1); // Wait 1 second to ensure different timestamp (time() has 1-second resolution) + $key2 = $method->invoke($output); + + $this->assertStringStartsWith('newsletter_signup-', $key1); + $this->assertStringStartsWith('newsletter_signup-', $key2); + // Keys should be different due to timestamp + $this->assertNotEquals($key1, $key2); + } + + public function testGetPathReturnsConfiguredPath(): void + { + $form = $this->createMock(FormInterface::class); + $config = [ + 'class' => 'FormSubmission', + 'path' => '/custom/path/submissions', + ]; + + $output = new DataObjectOutput(); + $output->initialize('test', $form, $config); + + $reflection = new \ReflectionClass($output); + $method = $reflection->getMethod('getPath'); + $method->setAccessible(true); + + $result = $method->invoke($output); + + $this->assertEquals('/custom/path/submissions', $result); + } + + public function testGetDataReturnsFormData(): void + { + $form = $this->createMock(FormInterface::class); + $formData = [ + 'firstName' => 'Jane', + 'lastName' => 'Smith', + 'company' => 'ACME Corp', + ]; + $form->method('getData')->willReturn($formData); + + $config = [ + 'class' => 'FormSubmission', + 'path' => '/submissions', + ]; + + $output = new DataObjectOutput(); + $output->initialize('test', $form, $config); + + $reflection = new \ReflectionClass($output); + $method = $reflection->getMethod('getData'); + $method->setAccessible(true); + + $result = $method->invoke($output); + + $this->assertEquals($formData, $result); + } +} diff --git a/tests/Unit/Form/Output/EmailOutputTest.php b/tests/Unit/Form/Output/EmailOutputTest.php new file mode 100644 index 0000000..a63cc92 --- /dev/null +++ b/tests/Unit/Form/Output/EmailOutputTest.php @@ -0,0 +1,204 @@ +assertEquals('email', EmailOutput::name()); + } + + public function testHandleSendsEmailSuccessfully(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getData')->willReturn([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + ]); + + $config = [ + 'to' => 'admin@example.com', + 'document' => 5, + ]; + + // Create a custom output class that allows us to inject the mail mock + $mailMock = $this->createMockPimcoreMail(); + $mailMock->expects($this->once())->method('addTo')->with('admin@example.com'); + $mailMock->expects($this->once())->method('setDocument')->with(5); + $mailMock->expects($this->once())->method('setParams')->with([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + ]); + $mailMock->expects($this->once())->method('send'); + + $output = new class($mailMock) extends EmailOutput { + public function __construct(private Mail $mockMail) + { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + $this->mockMail->addTo($this->getTo()); + $this->mockMail->setDocument($this->getDocument()); + $this->mockMail->setParams(array_merge($this->form->getData(), $this->getAdditionalParams())); + + $subject = $this->getSubject(); + + if ($subject !== null) { + $this->mockMail->subject($subject); + } + + $from = $this->getFrom(); + + if ($from !== null) { + $this->mockMail->addFrom($from); + } + + $this->mockMail = $this->preSend($this->mockMail); + $this->mockMail->send(); + + return $outputResponse->addStatus(true); + } + }; + + $output->initialize('email_admin', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertTrue($result->getOverallStatus()); + } + + public function testHandleWithDocumentPathSendsEmail(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getData')->willReturn(['test' => 'data']); + + $documentMock = $this->createMock(Document::class); + $documentMock->method('getId')->willReturn(10); + + $config = [ + 'to' => 'test@example.com', + 'document' => '/emails/test', + ]; + + $mailMock = $this->createMockPimcoreMail(); + $mailMock->expects($this->once())->method('addTo')->with('test@example.com'); + $mailMock->expects($this->once())->method('setDocument')->with('/emails/test'); + + $output = new class($mailMock) extends EmailOutput { + public function __construct(private Mail $mockMail) + { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + $this->mockMail->addTo($this->getTo()); + $this->mockMail->setDocument($this->getDocument()); + $this->mockMail->setParams(array_merge($this->form->getData(), $this->getAdditionalParams())); + $this->mockMail->send(); + + return $outputResponse->addStatus(true); + } + }; + + $output->initialize('email_test', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertTrue($result->getOverallStatus()); + } + + public function testHandleWithEmailSendFailureReturnsFailureStatus(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getData')->willReturn(['message' => 'test']); + + $config = [ + 'to' => 'admin@example.com', + 'document' => 5, + ]; + + $mailMock = $this->createFailingMockPimcoreMail(); + + $output = new class($mailMock) extends EmailOutput { + public function __construct(private Mail $mockMail) + { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + try { + $this->mockMail->addTo($this->getTo()); + $this->mockMail->setDocument($this->getDocument()); + $this->mockMail->setParams($this->form->getData()); + $this->mockMail->send(); + + return $outputResponse->addStatus(true); + } catch (\Exception $e) { + return $outputResponse->addStatus(false); + } + } + }; + + $output->initialize('email_fail', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertFalse($result->getOverallStatus()); + } + + public function testGetToReturnsConfiguredEmailAddress(): void + { + $form = $this->createMock(FormInterface::class); + $config = ['to' => 'recipient@example.com', 'document' => 1]; + + $output = new EmailOutput(); + $output->initialize('test', $form, $config); + + // Use reflection to access protected method + $reflection = new \ReflectionClass($output); + $method = $reflection->getMethod('getTo'); + $method->setAccessible(true); + + $result = $method->invoke($output); + + $this->assertEquals('recipient@example.com', $result); + } + + public function testGetDocumentReturnsConfiguredDocument(): void + { + $form = $this->createMock(FormInterface::class); + $config = ['to' => 'test@example.com', 'document' => '/system/emails/form']; + + $output = new EmailOutput(); + $output->initialize('test', $form, $config); + + $reflection = new \ReflectionClass($output); + $method = $reflection->getMethod('getDocument'); + $method->setAccessible(true); + + $result = $method->invoke($output); + + $this->assertEquals('/system/emails/form', $result); + } +} diff --git a/tests/Unit/Form/Output/HttpOutputTest.php b/tests/Unit/Form/Output/HttpOutputTest.php new file mode 100644 index 0000000..dbc4ff5 --- /dev/null +++ b/tests/Unit/Form/Output/HttpOutputTest.php @@ -0,0 +1,200 @@ +assertEquals('http', HttpOutput::name()); + } + + public function testHandleSendsJsonPostRequest(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getData')->willReturn([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + ]); + + $config = [ + 'url' => 'https://example.com/webhook', + ]; + + // Track that the correct data was "sent" + $capturedData = null; + + $output = new class($capturedData) extends HttpOutput { + public function __construct(private &$capturedData) + { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + // Simulate successful HTTP POST without actually making the request + $this->capturedData = [ + 'url' => $this->config['url'], + 'data' => $this->form->getData(), + ]; + + return $outputResponse->addStatus(true); + } + }; + + $output->initialize('http_webhook', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertTrue($result->getOverallStatus()); + $this->assertEquals('https://example.com/webhook', $capturedData['url']); + $this->assertEquals([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + ], $capturedData['data']); + } + + public function testHandleWithHttpFailureReturnsFailureStatus(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getData')->willReturn(['message' => 'test']); + + $config = [ + 'url' => 'https://example.com/failing-webhook', + ]; + + $output = new class extends HttpOutput { + public function handle(OutputResponse $outputResponse): OutputResponse + { + // Simulate HTTP failure (curl_exec returns false) + return $outputResponse->addStatus(false); + } + }; + + $output->initialize('http_fail', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertFalse($result->getOverallStatus()); + } + + public function testHandleWithInvalidUrlThrowsException(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getData')->willReturn(['test' => 'data']); + + $config = [ + 'url' => 'invalid-url', + ]; + + $output = new class extends HttpOutput { + public function handle(OutputResponse $outputResponse): OutputResponse + { + // Simulate curl_init failure + throw new \RuntimeException('Failed to initialize curl for invalid-url'); + } + }; + + $output->initialize('http_invalid', $form, $config); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to initialize curl for invalid-url'); + + $response = new OutputResponse(); + $output->handle($response); + } + + public function testHandleSendsCorrectJsonHeaders(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getData')->willReturn(['key' => 'value']); + + $config = [ + 'url' => 'https://api.example.com/endpoint', + ]; + + $capturedHeaders = null; + + $output = new class($capturedHeaders) extends HttpOutput { + public function __construct(private &$capturedHeaders) + { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + // Simulate capturing the headers that would be sent + $this->capturedHeaders = ['Content-Type' => 'application/json']; + + return $outputResponse->addStatus(true); + } + }; + + $output->initialize('http_api', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertTrue($result->getOverallStatus()); + $this->assertEquals(['Content-Type' => 'application/json'], $capturedHeaders); + } + + public function testHandleWithComplexDataEncodesAsJson(): void + { + $form = $this->createMock(FormInterface::class); + $complexData = [ + 'user' => [ + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + 'preferences' => [ + 'newsletter' => true, + 'notifications' => false, + ], + ], + 'items' => ['item1', 'item2', 'item3'], + 'timestamp' => 1234567890, + ]; + $form->method('getData')->willReturn($complexData); + + $config = [ + 'url' => 'https://example.com/complex-webhook', + ]; + + $capturedJson = null; + + $output = new class($capturedJson) extends HttpOutput { + public function __construct(private &$capturedJson) + { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + // Capture the JSON that would be sent + $this->capturedJson = json_encode($this->form->getData(), \JSON_THROW_ON_ERROR); + + return $outputResponse->addStatus(true); + } + }; + + $output->initialize('http_complex', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertTrue($result->getOverallStatus()); + $this->assertJson($capturedJson); + $decoded = json_decode($capturedJson, true); + $this->assertEquals($complexData, $decoded); + } +} diff --git a/tests/Unit/Form/Output/LogOutputTest.php b/tests/Unit/Form/Output/LogOutputTest.php new file mode 100644 index 0000000..a10b576 --- /dev/null +++ b/tests/Unit/Form/Output/LogOutputTest.php @@ -0,0 +1,191 @@ +assertEquals('log', LogOutput::name()); + } + + public function testHandleLogsFormDataSuccessfully(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('contact_form'); + $formData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + ]; + $form->method('getData')->willReturn($formData); + + $config = [ + 'level' => 'info', + ]; + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('log') + ->with('info', 'contact_form', $formData) + ; + + $output = new LogOutput(); + $output->setLogger($logger); + $output->initialize('log_contact', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertTrue($result->getOverallStatus()); + } + + public function testHandleWithoutLogLevelUsesDebugDefault(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('test_form'); + $form->method('getData')->willReturn(['test' => 'data']); + + $config = []; // No level specified + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('log') + ->with('debug', 'test_form', ['test' => 'data']) + ; + + $output = new LogOutput(); + $output->setLogger($logger); + $output->initialize('log_test', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertTrue($result->getOverallStatus()); + } + + public function testHandleWithoutLoggerReturnsFailureStatus(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('test_form'); + $form->method('getData')->willReturn(['test' => 'data']); + + $config = ['level' => 'error']; + + $output = new LogOutput(); + // Deliberately not setting a logger + $output->initialize('log_noLogger', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertFalse($result->getOverallStatus()); + } + + public function testHandleWithWarningLevelLogsWarning(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('error_report'); + $form->method('getData')->willReturn(['error' => 'Something went wrong']); + + $config = ['level' => 'warning']; + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('log') + ->with('warning', 'error_report', ['error' => 'Something went wrong']) + ; + + $output = new LogOutput(); + $output->setLogger($logger); + $output->initialize('log_warning', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertTrue($result->getOverallStatus()); + } + + public function testHandleWithErrorLevelLogsError(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('critical_issue'); + $form->method('getData')->willReturn(['issue' => 'Critical failure']); + + $config = ['level' => 'error']; + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('log') + ->with('error', 'critical_issue', ['issue' => 'Critical failure']) + ; + + $output = new LogOutput(); + $output->setLogger($logger); + $output->initialize('log_error', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertTrue($result->getOverallStatus()); + } + + public function testHandleWithComplexDataLogsFullArray(): void + { + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('complex_form'); + $complexData = [ + 'user' => [ + 'name' => 'Jane Doe', + 'preferences' => ['newsletter' => true, 'sms' => false], + ], + 'metadata' => [ + 'timestamp' => 1234567890, + 'ip' => '192.168.1.1', + ], + ]; + $form->method('getData')->willReturn($complexData); + + $config = ['level' => 'info']; + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('log') + ->with('info', 'complex_form', $complexData) + ; + + $output = new LogOutput(); + $output->setLogger($logger); + $output->initialize('log_complex', $form, $config); + + $response = new OutputResponse(); + $result = $output->handle($response); + + $this->assertTrue($result->getOverallStatus()); + } + + public function testSetLoggerImplementsLoggerAwareInterface(): void + { + $logger = $this->createMock(LoggerInterface::class); + $output = new LogOutput(); + + $output->setLogger($logger); + + // Verify the logger was set by using reflection to access the protected property + $reflection = new \ReflectionClass($output); + $property = $reflection->getProperty('logger'); + $property->setAccessible(true); + + $this->assertSame($logger, $property->getValue($output)); + } +} diff --git a/tests/Unit/Form/Transformer/ArrayTransformerTest.php b/tests/Unit/Form/Transformer/ArrayTransformerTest.php new file mode 100644 index 0000000..831560e --- /dev/null +++ b/tests/Unit/Form/Transformer/ArrayTransformerTest.php @@ -0,0 +1,140 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->formFactory = Forms::createFormFactoryBuilder()->getFormFactory(); + $this->resolver = new Resolver(); + + $this->transformer = new ArrayTransformer($this->translator, $this->resolver); + + // Register transformers for nested fields + $this->resolver->setTransformer('text', new StringTransformer($this->translator, null)); + $this->resolver->setTransformer('collection', $this->transformer); + } + + public function testTransformBasicArray(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(CollectionType::class, null, [ + 'entry_type' => TextType::class, + 'label' => 'Items', + 'allow_add' => true, + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('array', $schema['type']); + $this->assertSame('Items', $schema['title']); + } + + public function testTransformArrayWithNestedArrays(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(CollectionType::class, null, [ + 'entry_type' => CollectionType::class, + 'entry_options' => [ + 'entry_type' => TextType::class, + 'allow_add' => true, + ], + 'label' => 'Nested Items', + 'allow_add' => true, + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('array', $schema['type']); + $this->assertSame('Nested Items', $schema['title']); + $this->assertArrayHasKey('items', $schema); + } + + public function testTransformArrayWithCustomLabel(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(CollectionType::class, null, [ + 'entry_type' => TextType::class, + 'label' => 'Custom Label', + 'allow_add' => true, + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('Custom Label', $schema['title']); + } + + public function testTransformArrayWithAttributes(): void + { + $this->translator + ->method('trans') + ->willReturnCallback(fn ($key) => $key) + ; + + $form = $this->formFactory + ->createBuilder(CollectionType::class, null, [ + 'entry_type' => TextType::class, + 'label' => 'Items with Attrs', + 'allow_add' => true, + 'attr' => [ + 'class' => 'collection-field', + 'data-test' => 'true', + ], + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertArrayHasKey('attr', $schema); + $this->assertSame('collection-field', $schema['attr']['class']); + $this->assertSame('true', $schema['attr']['data-test']); + } +} diff --git a/tests/Unit/Form/Transformer/BooleanTransformerTest.php b/tests/Unit/Form/Transformer/BooleanTransformerTest.php new file mode 100644 index 0000000..bc4f2a3 --- /dev/null +++ b/tests/Unit/Form/Transformer/BooleanTransformerTest.php @@ -0,0 +1,100 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->formFactory = Forms::createFormFactoryBuilder()->getFormFactory(); + + $this->transformer = new BooleanTransformer($this->translator, null); + } + + public function testTransformBasicBoolean(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(CheckboxType::class, null, [ + 'label' => 'Accept Terms', + 'required' => false, + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('boolean', $schema['type']); + $this->assertSame('Accept Terms', $schema['title']); + } + + public function testTransformBooleanWithCustomLabel(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(CheckboxType::class, null, [ + 'label' => 'Subscribe to Newsletter', + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('boolean', $schema['type']); + $this->assertSame('Subscribe to Newsletter', $schema['title']); + } + + public function testTransformBooleanWithAttributes(): void + { + $this->translator + ->method('trans') + ->willReturnCallback(fn ($key) => $key) + ; + + $form = $this->formFactory + ->createBuilder(CheckboxType::class, null, [ + 'label' => 'Enable Feature', + 'attr' => [ + 'class' => 'custom-checkbox', + 'data-enabled' => 'true', + ], + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('boolean', $schema['type']); + $this->assertArrayHasKey('attr', $schema); + $this->assertSame('custom-checkbox', $schema['attr']['class']); + $this->assertSame('true', $schema['attr']['data-enabled']); + } +} diff --git a/tests/Unit/Form/Transformer/ButtonTransformerTest.php b/tests/Unit/Form/Transformer/ButtonTransformerTest.php new file mode 100644 index 0000000..c6f614a --- /dev/null +++ b/tests/Unit/Form/Transformer/ButtonTransformerTest.php @@ -0,0 +1,73 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->formFactory = Forms::createFormFactoryBuilder()->getFormFactory(); + + $this->transformer = new ButtonTransformer($this->translator, null); + } + + public function testTransformButton(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(ButtonType::class, null, [ + 'label' => 'Click Me', + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('string', $schema['type']); + $this->assertSame('Click Me', $schema['title']); + } + + public function testTransformSubmitButton(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(SubmitType::class, null, [ + 'label' => 'Submit Form', + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('string', $schema['type']); + $this->assertSame('Submit Form', $schema['title']); + } +} diff --git a/tests/Unit/Form/Transformer/ChoiceTransformerTest.php b/tests/Unit/Form/Transformer/ChoiceTransformerTest.php new file mode 100644 index 0000000..4780e9b --- /dev/null +++ b/tests/Unit/Form/Transformer/ChoiceTransformerTest.php @@ -0,0 +1,173 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->formFactory = Forms::createFormFactoryBuilder()->getFormFactory(); + + $this->transformer = new ChoiceTransformer($this->translator, null); + } + + public function testTransformSingleChoice(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(ChoiceType::class, null, [ + 'label' => 'Country', + 'choices' => [ + 'Germany' => 'de', + 'France' => 'fr', + 'Spain' => 'es', + ], + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('string', $schema['type']); + $this->assertArrayHasKey('enum', $schema); + $this->assertArrayHasKey('enum_titles', $schema); + $this->assertContains('de', $schema['enum']); + $this->assertContains('fr', $schema['enum']); + $this->assertContains('es', $schema['enum']); + } + + public function testTransformMultipleChoice(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(ChoiceType::class, null, [ + 'label' => 'Languages', + 'multiple' => true, + 'choices' => [ + 'English' => 'en', + 'German' => 'de', + 'French' => 'fr', + ], + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('array', $schema['type']); + $this->assertArrayHasKey('items', $schema); + $this->assertSame('string', $schema['items']['type']); + $this->assertArrayHasKey('enum', $schema['items']); + $this->assertTrue($schema['uniqueItems']); + } + + public function testTransformExpandedChoice(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(ChoiceType::class, null, [ + 'label' => 'Gender', + 'expanded' => true, + 'choices' => [ + 'Male' => 'm', + 'Female' => 'f', + 'Other' => 'o', + ], + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('string', $schema['type']); + $this->assertSame('choice-expanded', $schema['widget']); + } + + public function testTransformExpandedMultipleChoice(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(ChoiceType::class, null, [ + 'label' => 'Interests', + 'expanded' => true, + 'multiple' => true, + 'choices' => [ + 'Sports' => 'sports', + 'Music' => 'music', + 'Reading' => 'reading', + ], + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('array', $schema['type']); + $this->assertSame('choice-multiple-expanded', $schema['widget']); + } + + public function testTransformRequiredMultipleChoice(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(ChoiceType::class, null, [ + 'label' => 'Required Skills', + 'multiple' => true, + 'required' => true, + 'choices' => [ + 'PHP' => 'php', + 'JavaScript' => 'js', + ], + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('array', $schema['type']); + $this->assertArrayHasKey('minItems', $schema); + $this->assertSame(1, $schema['minItems']); + } +} diff --git a/tests/Unit/Form/Transformer/CompoundTransformerTest.php b/tests/Unit/Form/Transformer/CompoundTransformerTest.php new file mode 100644 index 0000000..0fa0d61 --- /dev/null +++ b/tests/Unit/Form/Transformer/CompoundTransformerTest.php @@ -0,0 +1,175 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->formFactory = Forms::createFormFactoryBuilder()->getFormFactory(); + $this->resolver = new Resolver(); + + $this->transformer = new CompoundTransformer($this->translator, $this->resolver); + + // Register transformers for nested fields + $this->resolver->setTransformer('text', new StringTransformer($this->translator, null)); + $this->resolver->setTransformer('email', new StringTransformer($this->translator, null)); + $this->resolver->setTransformer('form', $this->transformer); + $this->resolver->setTransformer('compound', $this->transformer); + } + + public function testTransformNestedForm(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(FormType::class, null, [ + 'label' => 'Contact Form', + ]) + ->add('name', TextType::class, [ + 'label' => 'Name', + ]) + ->add('email', EmailType::class, [ + 'label' => 'Email', + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('object', $schema['type']); + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('name', $schema['properties']); + $this->assertArrayHasKey('email', $schema['properties']); + } + + public function testTransformNestedObjectWithProperties(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(FormType::class, null, [ + 'label' => 'User Profile', + ]) + ->add('firstName', TextType::class, ['label' => 'First Name']) + ->add('lastName', TextType::class, ['label' => 'Last Name']) + ->add('email', EmailType::class, ['label' => 'Email Address']) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('object', $schema['type']); + $this->assertArrayHasKey('properties', $schema); + $this->assertCount(3, $schema['properties']); + } + + public function testTransformNestedFormWithCustomLabel(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(FormType::class, null, [ + 'label' => 'Address Information', + ]) + ->add('street', TextType::class, ['label' => 'Street']) + ->add('city', TextType::class, ['label' => 'City']) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('Address Information', $schema['title']); + } + + public function testTransformDeeplyNestedObjects(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(FormType::class, null, [ + 'label' => 'User', + ]) + ->add('name', TextType::class) + ->add('address', FormType::class, [ + 'label' => 'Address', + ]) + ->getForm() + ; + + $form->get('address') + ->add('street', TextType::class) + ->add('city', TextType::class) + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('object', $schema['type']); + $this->assertArrayHasKey('properties', $schema); + } + + public function testTransformCompoundWithAttributes(): void + { + $this->translator + ->method('trans') + ->willReturnCallback(fn ($key) => $key) + ; + + $form = $this->formFactory + ->createBuilder(FormType::class, null, [ + 'label' => 'Form with Attrs', + 'attr' => [ + 'class' => 'compound-form', + 'data-test' => 'nested', + ], + ]) + ->add('field1', TextType::class) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('object', $schema['type']); + $this->assertArrayHasKey('attr', $schema); + $this->assertSame('compound-form', $schema['attr']['class']); + } +} diff --git a/tests/Unit/Form/Transformer/FileTransformerTest.php b/tests/Unit/Form/Transformer/FileTransformerTest.php new file mode 100644 index 0000000..da7a2f0 --- /dev/null +++ b/tests/Unit/Form/Transformer/FileTransformerTest.php @@ -0,0 +1,99 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->formFactory = Forms::createFormFactoryBuilder()->getFormFactory(); + + $this->transformer = new FileTransformer($this->translator, null); + } + + public function testTransformBasicFile(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(FileType::class, null, [ + 'label' => 'Upload Document', + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('file', $schema['type']); + $this->assertSame('Upload Document', $schema['title']); + } + + public function testTransformFileWithCustomLabel(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(FileType::class, null, [ + 'label' => 'Profile Picture', + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('file', $schema['type']); + $this->assertSame('Profile Picture', $schema['title']); + } + + public function testTransformFileWithAttributes(): void + { + $this->translator + ->method('trans') + ->willReturnCallback(fn ($key) => $key) + ; + + $form = $this->formFactory + ->createBuilder(FileType::class, null, [ + 'label' => 'Attachment', + 'attr' => [ + 'accept' => '.pdf,.doc,.docx', + 'class' => 'file-input', + ], + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('file', $schema['type']); + $this->assertArrayHasKey('attr', $schema); + $this->assertSame('.pdf,.doc,.docx', $schema['attr']['accept']); + $this->assertSame('file-input', $schema['attr']['class']); + } +} diff --git a/tests/Unit/Form/Transformer/IntegerTransformerTest.php b/tests/Unit/Form/Transformer/IntegerTransformerTest.php new file mode 100644 index 0000000..1e64061 --- /dev/null +++ b/tests/Unit/Form/Transformer/IntegerTransformerTest.php @@ -0,0 +1,78 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->formFactory = Forms::createFormFactoryBuilder()->getFormFactory(); + + $this->transformer = new IntegerTransformer($this->translator, null); + } + + public function testTransformBasicInteger(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(IntegerType::class, null, [ + 'label' => 'Age', + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('integer', $schema['type']); + $this->assertSame('Age', $schema['title']); + } + + public function testTransformIntegerWithAttributes(): void + { + $this->translator + ->method('trans') + ->willReturnCallback(fn ($key) => $key) + ; + + $form = $this->formFactory + ->createBuilder(IntegerType::class, null, [ + 'label' => 'Quantity', + 'attr' => [ + 'min' => 1, + 'max' => 100, + ], + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('integer', $schema['type']); + $this->assertArrayHasKey('attr', $schema); + $this->assertSame(1, $schema['attr']['min']); + $this->assertSame(100, $schema['attr']['max']); + } +} diff --git a/tests/Unit/Form/Transformer/NumberTransformerTest.php b/tests/Unit/Form/Transformer/NumberTransformerTest.php new file mode 100644 index 0000000..99c32db --- /dev/null +++ b/tests/Unit/Form/Transformer/NumberTransformerTest.php @@ -0,0 +1,102 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->formFactory = Forms::createFormFactoryBuilder()->getFormFactory(); + + $this->transformer = new NumberTransformer($this->translator, null); + } + + public function testTransformBasicNumber(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(NumberType::class, null, [ + 'label' => 'Price', + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('number', $schema['type']); + $this->assertSame('Price', $schema['title']); + } + + public function testTransformNumberWithDecimalHandling(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(NumberType::class, null, [ + 'label' => 'Amount', + 'scale' => 2, + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('number', $schema['type']); + $this->assertSame('Amount', $schema['title']); + } + + public function testTransformNumberWithAttributes(): void + { + $this->translator + ->method('trans') + ->willReturnCallback(fn ($key) => $key) + ; + + $form = $this->formFactory + ->createBuilder(NumberType::class, null, [ + 'label' => 'Percentage', + 'attr' => [ + 'min' => 0, + 'max' => 100, + 'step' => 0.01, + ], + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('number', $schema['type']); + $this->assertArrayHasKey('attr', $schema); + $this->assertSame(0, $schema['attr']['min']); + $this->assertSame(100, $schema['attr']['max']); + $this->assertSame(0.01, $schema['attr']['step']); + } +} diff --git a/tests/Unit/Form/Transformer/StringTransformerTest.php b/tests/Unit/Form/Transformer/StringTransformerTest.php new file mode 100644 index 0000000..e733cb2 --- /dev/null +++ b/tests/Unit/Form/Transformer/StringTransformerTest.php @@ -0,0 +1,107 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->formFactory = Forms::createFormFactoryBuilder()->getFormFactory(); + + $this->transformer = new StringTransformer($this->translator, null); + } + + public function testTransformBasicString(): void + { + $this->translator + ->method('trans') + ->willReturnArgument(0) + ; + + $form = $this->formFactory + ->createBuilder(TextType::class, null, [ + 'label' => 'Name', + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('string', $schema['type']); + $this->assertSame('Name', $schema['title']); + } + + public function testTransformStringWithAttributes(): void + { + $this->translator + ->method('trans') + ->willReturnCallback(fn ($key) => $key) + ; + + $form = $this->formFactory + ->createBuilder(TextType::class, null, [ + 'label' => 'Username', + 'attr' => [ + 'minlength' => 3, + 'maxlength' => 20, + 'pattern' => '^[a-zA-Z0-9_]+$', + ], + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('string', $schema['type']); + $this->assertArrayHasKey('attr', $schema); + $this->assertSame(3, $schema['attr']['minlength']); + $this->assertSame(20, $schema['attr']['maxlength']); + $this->assertSame('^[a-zA-Z0-9_]+$', $schema['attr']['pattern']); + } + + public function testTransformStringWithPlaceholder(): void + { + $this->translator + ->method('trans') + ->willReturnCallback(fn ($key) => $key) + ; + + $form = $this->formFactory + ->createBuilder(TextType::class, null, [ + 'label' => 'Search', + 'attr' => [ + 'placeholder' => 'Enter search term...', + 'class' => 'search-input', + ], + ]) + ->getForm() + ; + + $schema = $this->transformer->transform($form); + + $this->assertIsArray($schema); + $this->assertSame('string', $schema['type']); + $this->assertArrayHasKey('attr', $schema); + $this->assertSame('Enter search term...', $schema['attr']['placeholder']); + $this->assertSame('search-input', $schema['attr']['class']); + } +} diff --git a/tests/Unit/Repository/ChoicesRepositoryTest.php b/tests/Unit/Repository/ChoicesRepositoryTest.php new file mode 100644 index 0000000..09e3411 --- /dev/null +++ b/tests/Unit/Repository/ChoicesRepositoryTest.php @@ -0,0 +1,111 @@ +get(TestChoiceProvider1::class); + + $this->assertInstanceOf(ChoicesInterface::class, $retrieved); + $this->assertSame($provider, $retrieved); + } + + public function testGetThrowsExceptionForUnknownProvider(): void + { + $this->expectException(ItemNotFoundInRepositoryException::class); + $this->expectExceptionMessage('Item NonExistentProvider not found in repository'); + + $repository = new ChoicesRepository([]); + $repository->get('NonExistentProvider'); + } + + public function testRepositoryHandlesMultipleProviders(): void + { + $provider1 = new TestChoiceProvider1(); + $provider2 = new TestChoiceProvider2(); + + $repository = new ChoicesRepository([$provider1, $provider2]); + + $retrieved1 = $repository->get(TestChoiceProvider1::class); + $retrieved2 = $repository->get(TestChoiceProvider2::class); + + $this->assertSame($provider1, $retrieved1); + $this->assertSame($provider2, $retrieved2); + } + + public function testGetCachesProviders(): void + { + $provider = new TestChoiceProvider1(); + $repository = new ChoicesRepository([$provider]); + + $retrieved1 = $repository->get(TestChoiceProvider1::class); + $retrieved2 = $repository->get(TestChoiceProvider1::class); + + $this->assertSame($retrieved1, $retrieved2); + } + + public function testRepositoryIteratesOverIterableProviders(): void + { + $provider1 = new TestChoiceProvider1(); + $provider2 = new TestChoiceProvider2(); + + $iterator = new \ArrayIterator([$provider1, $provider2]); + $repository = new ChoicesRepository($iterator); + + $retrieved1 = $repository->get(TestChoiceProvider1::class); + $retrieved2 = $repository->get(TestChoiceProvider2::class); + + $this->assertInstanceOf(ChoicesInterface::class, $retrieved1); + $this->assertInstanceOf(ChoicesInterface::class, $retrieved2); + } +} + +class TestChoiceProvider1 implements ChoicesInterface +{ + public function choices(): array + { + return ['option1' => 'Option 1', 'option2' => 'Option 2']; + } + + public function choiceLabel(mixed $choice, mixed $key, mixed $value): ?string + { + return (string) $value; + } + + public function choiceAttribute(mixed $choice, mixed $key, mixed $value): array + { + return []; + } +} + +class TestChoiceProvider2 implements ChoicesInterface +{ + public function choices(): array + { + return ['a' => 'Choice A', 'b' => 'Choice B']; + } + + public function choiceLabel(mixed $choice, mixed $key, mixed $value): ?string + { + return (string) $value; + } + + public function choiceAttribute(mixed $choice, mixed $key, mixed $value): array + { + return ['data-custom' => 'value']; + } +} diff --git a/tests/Unit/Repository/ConfigurationRepositoryTest.php b/tests/Unit/Repository/ConfigurationRepositoryTest.php new file mode 100644 index 0000000..a362458 --- /dev/null +++ b/tests/Unit/Repository/ConfigurationRepositoryTest.php @@ -0,0 +1,93 @@ + [ + 'test_form' => [ + 'fields' => [], + ], + ], + ]; + + $parameterBag = new ParameterBag([ + ConfigurationRepository::CONTAINER_TAG => $config, + ]); + + $repository = new ConfigurationRepository($parameterBag); + $result = $repository->get(); + + $this->assertIsArray($result); + $this->assertArrayHasKey('forms', $result); + $this->assertEquals($config, $result); + } + + public function testGetThrowsExceptionWhenConfigIsNotArray(): void + { + $this->expectException(\RuntimeException::class); + + $parameterBag = new ParameterBag([ + ConfigurationRepository::CONTAINER_TAG => 'not-an-array', + ]); + + $repository = new ConfigurationRepository($parameterBag); + $repository->get(); + } + + public function testGetCachesConfiguration(): void + { + $config = ['forms' => []]; + + $parameterBag = new ParameterBag([ + ConfigurationRepository::CONTAINER_TAG => $config, + ]); + + $repository = new ConfigurationRepository($parameterBag); + + $result1 = $repository->get(); + $result2 = $repository->get(); + + $this->assertSame($result1, $result2); + } + + public function testGetWithComplexNestedConfiguration(): void + { + $config = [ + 'forms' => [ + 'contact_form' => [ + 'fields' => [ + 'name' => ['type' => 'text'], + 'email' => ['type' => 'email'], + ], + 'outputs' => [ + 'email' => ['to' => 'test@example.com'], + ], + ], + ], + ]; + + $parameterBag = new ParameterBag([ + ConfigurationRepository::CONTAINER_TAG => $config, + ]); + + $repository = new ConfigurationRepository($parameterBag); + $result = $repository->get(); + + $this->assertEquals($config, $result); + $this->assertArrayHasKey('contact_form', $result['forms']); + $this->assertArrayHasKey('fields', $result['forms']['contact_form']); + $this->assertArrayHasKey('outputs', $result['forms']['contact_form']); + } +} diff --git a/tests/Unit/Repository/InputHandlerRepositoryTest.php b/tests/Unit/Repository/InputHandlerRepositoryTest.php new file mode 100644 index 0000000..52177f0 --- /dev/null +++ b/tests/Unit/Repository/InputHandlerRepositoryTest.php @@ -0,0 +1,92 @@ +get(TestInputHandler1::class); + + $this->assertInstanceOf(InputHandlerInterface::class, $retrieved); + $this->assertSame($handler, $retrieved); + } + + public function testGetThrowsExceptionForUnknownHandler(): void + { + $this->expectException(ItemNotFoundInRepositoryException::class); + $this->expectExceptionMessage('Item NonExistentHandler not found in repository'); + + $repository = new InputHandlerRepository([]); + $repository->get('NonExistentHandler'); + } + + public function testRepositoryHandlesMultipleHandlers(): void + { + $handler1 = new TestInputHandler1(); + $handler2 = new TestInputHandler2(); + + $repository = new InputHandlerRepository([$handler1, $handler2]); + + $retrieved1 = $repository->get(TestInputHandler1::class); + $retrieved2 = $repository->get(TestInputHandler2::class); + + $this->assertSame($handler1, $retrieved1); + $this->assertSame($handler2, $retrieved2); + } + + public function testGetCachesHandlers(): void + { + $handler = new TestInputHandler1(); + $repository = new InputHandlerRepository([$handler]); + + $retrieved1 = $repository->get(TestInputHandler1::class); + $retrieved2 = $repository->get(TestInputHandler1::class); + + $this->assertSame($retrieved1, $retrieved2); + } +} + +class TestInputHandler1 implements InputHandlerInterface +{ + private array $data = []; + + public function initialize(FormInterface $form, ?Request $request): void + { + $this->data = ['field1' => 'value1']; + } + + public function get(): array + { + return $this->data; + } +} + +class TestInputHandler2 implements InputHandlerInterface +{ + private array $data = []; + + public function initialize(FormInterface $form, ?Request $request): void + { + $this->data = ['field2' => 'value2']; + } + + public function get(): array + { + return $this->data; + } +} diff --git a/tests/Unit/Repository/OutputRepositoryTest.php b/tests/Unit/Repository/OutputRepositoryTest.php new file mode 100644 index 0000000..51f7b26 --- /dev/null +++ b/tests/Unit/Repository/OutputRepositoryTest.php @@ -0,0 +1,165 @@ +get('test_output_1'); + + $this->assertInstanceOf(OutputInterface::class, $retrieved); + } + + public function testGetThrowsExceptionForUnknownOutput(): void + { + $this->expectException(UnknownOutputException::class); + + $repository = new OutputRepository([]); + $repository->get('nonexistent'); + } + + public function testGetReturnsClonedInstance(): void + { + $output = new TestOutput1(); + $repository = new OutputRepository([$output]); + + $retrieved1 = $repository->get('test_output_1'); + $retrieved2 = $repository->get('test_output_1'); + + $this->assertNotSame($retrieved1, $retrieved2); + } + + public function testAllReturnsAllOutputs(): void + { + $output1 = new TestOutput1(); + $output2 = new TestOutput2(); + + $repository = new OutputRepository([$output1, $output2]); + $all = $repository->all(); + + $this->assertCount(2, $all); + $this->assertArrayHasKey('test_output_1', $all); + $this->assertArrayHasKey('test_output_2', $all); + } + + public function testIterableToArrayConvertsIterableToArray(): void + { + $output = new TestOutput1(); + $repository = new OutputRepository([$output]); + + $result = $repository->iterableToArray([$output]); + + $this->assertIsArray($result); + $this->assertArrayHasKey('test_output_1', $result); + } + + public function testIterableToArrayThrowsExceptionForDuplicateNames(): void + { + $this->expectException(DuplicateOutputException::class); + + $output1 = new TestOutputDuplicate(); + $output2 = new TestOutputDuplicate2(); + + new OutputRepository([$output1, $output2]); + } +} + +class TestOutput1 implements OutputInterface +{ + public static function name(): string + { + return 'test_output_1'; + } + + public function initialize(string $key, FormInterface $form, array $config): void + { + } + + public function setOutputHandlers(array $handlers): void + { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + return $outputResponse; + } +} + +class TestOutput2 implements OutputInterface +{ + public static function name(): string + { + return 'test_output_2'; + } + + public function initialize(string $key, FormInterface $form, array $config): void + { + } + + public function setOutputHandlers(array $handlers): void + { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + return $outputResponse; + } +} + +class TestOutputDuplicate implements OutputInterface +{ + public static function name(): string + { + return 'duplicate'; + } + + public function initialize(string $key, FormInterface $form, array $config): void + { + } + + public function setOutputHandlers(array $handlers): void + { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + return $outputResponse; + } +} + +class TestOutputDuplicate2 implements OutputInterface +{ + public static function name(): string + { + return 'duplicate'; + } + + public function initialize(string $key, FormInterface $form, array $config): void + { + } + + public function setOutputHandlers(array $handlers): void + { + } + + public function handle(OutputResponse $outputResponse): OutputResponse + { + return $outputResponse; + } +} diff --git a/tests/Unit/Repository/RedirectHandlerRepositoryTest.php b/tests/Unit/Repository/RedirectHandlerRepositoryTest.php new file mode 100644 index 0000000..33ef8ad --- /dev/null +++ b/tests/Unit/Repository/RedirectHandlerRepositoryTest.php @@ -0,0 +1,86 @@ +get(TestRedirectHandler1::class); + + $this->assertInstanceOf(RedirectHandlerInterface::class, $retrieved); + $this->assertSame($handler, $retrieved); + } + + public function testGetThrowsExceptionForUnknownHandler(): void + { + $this->expectException(ItemNotFoundInRepositoryException::class); + $this->expectExceptionMessage('Item NonExistentHandler not found in repository'); + + $repository = new RedirectHandlerRepository([]); + $repository->get('NonExistentHandler'); + } + + public function testRepositoryHandlesMultipleHandlers(): void + { + $handler1 = new TestRedirectHandler1(); + $handler2 = new TestRedirectHandler2(); + + $repository = new RedirectHandlerRepository([$handler1, $handler2]); + + $retrieved1 = $repository->get(TestRedirectHandler1::class); + $retrieved2 = $repository->get(TestRedirectHandler2::class); + + $this->assertSame($handler1, $retrieved1); + $this->assertSame($handler2, $retrieved2); + } + + public function testGetCachesHandlers(): void + { + $handler = new TestRedirectHandler1(); + $repository = new RedirectHandlerRepository([$handler]); + + $retrieved1 = $repository->get(TestRedirectHandler1::class); + $retrieved2 = $repository->get(TestRedirectHandler1::class); + + $this->assertSame($retrieved1, $retrieved2); + } +} + +class TestRedirectHandler1 implements RedirectHandlerInterface +{ + public function onSuccess(): ?string + { + return '/success'; + } + + public function onFailure(): ?string + { + return '/failure'; + } +} + +class TestRedirectHandler2 implements RedirectHandlerInterface +{ + public function onSuccess(): ?string + { + return '/thank-you'; + } + + public function onFailure(): ?string + { + return null; + } +} diff --git a/tests/Unit/Service/FormServiceTest.php b/tests/Unit/Service/FormServiceTest.php new file mode 100644 index 0000000..3d7187c --- /dev/null +++ b/tests/Unit/Service/FormServiceTest.php @@ -0,0 +1,276 @@ +configRepository = $this->createMock(ConfigurationRepository::class); + $this->outputRepository = $this->createMock(OutputRepository::class); + $this->redirectHandlerRepository = $this->createMock(RedirectHandlerRepository::class); + $this->inputHandlerRepository = $this->createMock(InputHandlerRepository::class); + $this->builder = $this->createMock(Builder::class); + $this->liform = $this->createMock(Liform::class); + $this->errorNormalizer = $this->createMock(FormErrorNormalizer::class); + $this->requestStack = $this->createMock(RequestStack::class); + + $this->service = new FormService( + $this->configRepository, + $this->outputRepository, + $this->redirectHandlerRepository, + $this->inputHandlerRepository, + $this->builder, + $this->liform, + $this->errorNormalizer, + $this->createMock(FormTypeExtension::class), + $this->createMock(FormNameExtension::class), + $this->createMock(FormConstraintExtension::class), + $this->createMock(FormAttributeExtension::class), + $this->createMock(ChoiceTypeExtension::class), + $this->createMock(HiddenTypeExtension::class), + $this->createMock(FormDataExtension::class), + $this->requestStack, + ); + } + + public function testBuildWithValidConfigCreatesFormBuilder(): void + { + $config = ConfigurationFactory::createValidFormConfig('test_form'); + $this->configRepository->method('get')->willReturn($config); + + $mockFormBuilder = $this->createFormBuilder('test_form'); + $this->builder->method('form')->willReturn($mockFormBuilder); + $this->builder->method('field')->willReturn([TextType::class, ['label' => 'Test']]); + + $result = $this->service->build('test_form'); + + $this->assertInstanceOf(FormBuilderInterface::class, $result); + $this->assertEquals('test_form', $result->getName()); + } + + public function testBuildWithInvalidFormNameThrowsException(): void + { + $this->configRepository->method('get')->willReturn(['forms' => []]); + + $this->expectException(InvalidFormConfigException::class); + + $this->service->build('nonexistent_form'); + } + + public function testBuildFormReturnsFormInterface(): void + { + $config = ConfigurationFactory::createValidFormConfig('test_form'); + $this->configRepository->method('get')->willReturn($config); + + $mockFormBuilder = $this->createFormBuilder('test_form'); + $this->builder->method('form')->willReturn($mockFormBuilder); + $this->builder->method('field')->willReturn([TextType::class, []]); + + $result = $this->service->buildForm('test_form'); + + $this->assertInstanceOf(FormInterface::class, $result); + } + + public function testJsonTransformsFormToArray(): void + { + $form = $this->createMock(FormInterface::class); + + $expectedSchema = [ + 'type' => 'object', + 'properties' => [ + ['name' => 'field1', 'type' => 'string'], + ['name' => 'field2', 'type' => 'string'], + ], + ]; + + $this->liform->method('transform')->willReturn($expectedSchema); + + $result = $this->service->json($form); + + $this->assertIsArray($result); + $this->assertArrayHasKey('properties', $result); + $this->assertIsArray($result['properties']); + } + + public function testBuildJsonBuildsFormAndReturnsJson(): void + { + $config = ConfigurationFactory::createValidFormConfig('test_form'); + $this->configRepository->method('get')->willReturn($config); + + $mockFormBuilder = $this->createFormBuilder('test_form'); + $this->builder->method('form')->willReturn($mockFormBuilder); + $this->builder->method('field')->willReturn([TextType::class, []]); + + $jsonSchema = ['type' => 'object', 'properties' => []]; + $this->liform->method('transform')->willReturn($jsonSchema); + + $result = $this->service->buildJson('test_form'); + + $this->assertIsArray($result); + $this->assertEquals('object', $result['type']); + } + + public function testBuildJsonStringReturnsValidJsonString(): void + { + $config = ConfigurationFactory::createValidFormConfig('test_form'); + $this->configRepository->method('get')->willReturn($config); + + $mockFormBuilder = $this->createFormBuilder('test_form'); + $this->builder->method('form')->willReturn($mockFormBuilder); + $this->builder->method('field')->willReturn([TextType::class, []]); + + $jsonSchema = ['type' => 'object', 'properties' => []]; + $this->liform->method('transform')->willReturn($jsonSchema); + + $result = $this->service->buildJsonString('test_form'); + + $this->assertIsString($result); + $this->assertJson($result); + + $decoded = json_decode($result, true); + $this->assertEquals('object', $decoded['type']); + } + + public function testErrorsNormalizesFormErrors(): void + { + $form = $this->createMock(FormInterface::class); + + $expectedErrors = [ + ['message' => 'This field is required', 'type' => 'error', 'field' => 'email'], + ]; + + $this->errorNormalizer->method('normalize')->willReturn($expectedErrors); + + $result = $this->service->errors($form); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + $this->assertEquals('This field is required', $result[0]['message']); + } + + public function testOutputsExecutesOutputHandlers(): void + { + $config = ConfigurationFactory::createValidFormConfig('test_form'); + $this->configRepository->method('get')->willReturn($config); + + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('test_form'); + $form->method('getData')->willReturn(['name' => 'John']); + + $mockOutput = $this->createMock(OutputInterface::class); + $mockOutput->expects($this->once())->method('initialize'); + $mockOutput->expects($this->once())->method('setOutputHandlers'); + $mockOutput->method('handle')->willReturnCallback(fn (OutputResponse $response) => $response->addStatus(true)); + + $this->outputRepository->method('get')->willReturn($mockOutput); + + $result = $this->service->outputs($form); + + $this->assertInstanceOf(OutputResponse::class, $result); + $this->assertTrue($result->getOverallStatus()); + } + + public function testOutputsWithMultipleHandlersAggregatesStatus(): void + { + $config = ConfigurationFactory::createMultipleOutputsConfig('multi_form'); + $this->configRepository->method('get')->willReturn($config); + + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('multi_form'); + $form->method('getData')->willReturn(['message' => 'Test']); + + $successfulOutput = $this->createMock(OutputInterface::class); + $successfulOutput->method('handle')->willReturnCallback(fn (OutputResponse $response) => $response->addStatus(true)); + + $failedOutput = $this->createMock(OutputInterface::class); + $failedOutput->method('handle')->willReturnCallback(fn (OutputResponse $response) => $response->addStatus(false)); + + $this->outputRepository->method('get')->willReturnOnConsecutiveCalls( + $successfulOutput, + $failedOutput, + $successfulOutput, + ); + + $result = $this->service->outputs($form); + + // Overall status should be false since one handler failed + $this->assertFalse($result->getOverallStatus()); + } + + public function testGetRedirectUrlWithNoHandlerReturnsNull(): void + { + $config = ConfigurationFactory::createValidFormConfig('test_form'); + $this->configRepository->method('get')->willReturn($config); + + $form = $this->createMock(FormInterface::class); + $form->method('getName')->willReturn('test_form'); + + $result = $this->service->getRedirectUrl($form, true); + + $this->assertNull($result); + } + + public function testBuildWithCsrfProtectionAddsTokenField(): void + { + $config = ConfigurationFactory::createValidFormConfig('test_form'); + $config['forms']['test_form']['csrf'] = true; + $this->configRepository->method('get')->willReturn($config); + + $mockFormBuilder = $this->createFormBuilder('test_form'); + $this->builder->method('form')->willReturn($mockFormBuilder); + $this->builder->method('field')->willReturn([TextType::class, []]); + + $result = $this->service->build('test_form'); + + $this->assertInstanceOf(FormBuilderInterface::class, $result); + // CSRF field is added by the service via hidden type + // The actual field name depends on form options, typically '_token' + $form = $result->getForm(); + // We can verify the form builder was called correctly + $this->assertNotNull($form); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..6423cc8 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ +