diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..396494f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +# Unix-style newlines with a newline ending every file +end_of_line = lf +charset = utf-8 +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +# TS/JS-Files +[*.{ts,js}] +indent_size = 2 + +# ReST-Files +[*.rst] +indent_size = 3 +max_line_length = 80 + +[{ext_conf_template.txt}] +indent_size = 2 \ No newline at end of file diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..99265cc --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Pre-commit Git hook + +# Install all before tests +composer install + +composer php:cs +if [[ $? != 0 ]]; then + exit 1 +fi \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c3997af --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + - name: Install Dependencies + run: composer install --prefer-dist --no-progress + + - name: ci:php + run: composer php:cs + + - name: ci:unit + run: composer php:unit + + - name: functional tests + run: | + chmod +x ./Build/Scripts/runTests.sh + RUNTESTS_DIR_BIN=.Build/bin/ ./Build/Scripts/runTests.sh -p 8.3 -d mysql \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd5eae4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.Build +/.cache +/.idea +/composer.lock +/vendor +/typo3temp +/Build/phpunit/.phpunit.result.cache +.php-cs-fixer.cache +export.csv \ No newline at end of file diff --git a/Build/Scripts/runTests.sh b/Build/Scripts/runTests.sh new file mode 100755 index 0000000..7aa4cd5 --- /dev/null +++ b/Build/Scripts/runTests.sh @@ -0,0 +1,472 @@ +#!/usr/bin/env bash + +# +# TYPO3 core test runner based on docker or podman +# + +trap 'cleanUp;exit 2' SIGINT + +waitFor() { + local HOST=${1} + local PORT=${2} + local TESTCOMMAND=" + COUNT=0; + while ! nc -z ${HOST} ${PORT}; do + if [ \"\${COUNT}\" -gt 20 ]; then + echo \"Can not connect to ${HOST} port ${PORT}. Aborting.\"; + exit 1; + fi; + sleep 1; + COUNT=\$((COUNT + 1)); + done; + " + ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name wait-for-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${IMAGE_ALPINE} /bin/sh -c "${TESTCOMMAND}" + if [[ $? -gt 0 ]]; then + kill -SIGINT -$$ + fi +} + +cleanUp() { + ATTACHED_CONTAINERS=$(${CONTAINER_BIN} ps --filter network=${NETWORK} --format='{{.Names}}') + for ATTACHED_CONTAINER in ${ATTACHED_CONTAINERS}; do + ${CONTAINER_BIN} kill ${ATTACHED_CONTAINER} >/dev/null + done + if [ ${CONTAINER_BIN} = "docker" ]; then + ${CONTAINER_BIN} network rm ${NETWORK} >/dev/null + else + ${CONTAINER_BIN} network rm -f ${NETWORK} >/dev/null + fi +} + +handleDbmsOptions() { + # -a, -d, -i depend on each other. Validate input combinations and set defaults. + case ${DBMS} in + mariadb) + [ -z "${DATABASE_DRIVER}" ] && DATABASE_DRIVER="mysqli" + if [ "${DATABASE_DRIVER}" != "mysqli" ] && [ "${DATABASE_DRIVER}" != "pdo_mysql" ]; then + echo "Invalid combination -d ${DBMS} -a ${DATABASE_DRIVER}" >&2 + echo >&2 + echo "${HELP_MESSAGE}" >&2 + exit 1 + fi + [ -z "${DBMS_VERSION}" ] && DBMS_VERSION="10.4" + if ! [[ ${DBMS_VERSION} =~ ^(10.4|10.5|10.6|10.7|10.8|10.9|10.10|10.11|11.0|11.1)$ ]]; then + echo "Invalid combination -d ${DBMS} -i ${DBMS_VERSION}" >&2 + echo >&2 + echo "${HELP_MESSAGE}" >&2 + exit 1 + fi + ;; + mysql) + [ -z "${DATABASE_DRIVER}" ] && DATABASE_DRIVER="mysqli" + if [ "${DATABASE_DRIVER}" != "mysqli" ] && [ "${DATABASE_DRIVER}" != "pdo_mysql" ]; then + echo "Invalid combination -d ${DBMS} -a ${DATABASE_DRIVER}" >&2 + echo >&2 + echo "${HELP_MESSAGE}" >&2 + exit 1 + fi + [ -z "${DBMS_VERSION}" ] && DBMS_VERSION="8.0" + if ! [[ ${DBMS_VERSION} =~ ^(8.0|8.1|8.2|8.3)$ ]]; then + echo "Invalid combination -d ${DBMS} -i ${DBMS_VERSION}" >&2 + echo >&2 + echo "${HELP_MESSAGE}" >&2 + exit 1 + fi + ;; + postgres) + if [ -n "${DATABASE_DRIVER}" ]; then + echo "Invalid combination -d ${DBMS} -a ${DATABASE_DRIVER}" >&2 + echo >&2 + echo "${HELP_MESSAGE}" >&2 + exit 1 + fi + [ -z "${DBMS_VERSION}" ] && DBMS_VERSION="10" + if ! [[ ${DBMS_VERSION} =~ ^(10|11|12|13|14|15|16)$ ]]; then + echo "Invalid combination -d ${DBMS} -i ${DBMS_VERSION}" >&2 + echo >&2 + echo "${HELP_MESSAGE}" >&2 + exit 1 + fi + ;; + sqlite) + if [ -n "${DATABASE_DRIVER}" ]; then + echo "Invalid combination -d ${DBMS} -a ${DATABASE_DRIVER}" >&2 + echo >&2 + echo "${HELP_MESSAGE}" >&2 + exit 1 + fi + if [ -n "${DBMS_VERSION}" ]; then + echo "Invalid combination -d ${DBMS} -i ${DATABASE_DRIVER}" >&2 + echo >&2 + echo "${HELP_MESSAGE}" >&2 + exit 1 + fi + ;; + *) + echo "Invalid option -d ${DBMS}" >&2 + echo >&2 + echo "${HELP_MESSAGE}" >&2 + exit 1 + ;; + esac +} + +getPhpImageVersion() { + case ${1} in + 8.2) + echo -n "1.12" + ;; + 8.3) + echo -n "1.13" + ;; + esac +} + +loadHelp() { + # Load help text into $HELP + read -r -d '' HELP < + Container environment: + - podman (default) + - docker + + -a + Specifies to use another driver, following combinations are available: + - mysql + - mysqli (default) + - pdo_mysql + - mariadb + - mysqli (default) + - pdo_mysql + + -d + Specifies on which DBMS tests are performed + - sqlite: (default): use sqlite + - mariadb: use mariadb + - mysql: use MySQL + - postgres: use postgres + + -i version + Specify a specific database version + With "-d mariadb": + - 10.4 short-term, maintained until 2024-06-18 (default) + - 10.5 short-term, maintained until 2025-06-24 + - 10.6 long-term, maintained until 2026-06 + - 10.7 short-term, no longer maintained + - 10.8 short-term, maintained until 2023-05 + - 10.9 short-term, maintained until 2023-08 + - 10.10 short-term, maintained until 2023-11 + - 10.11 long-term, maintained until 2028-02 + - 11.0 development series + - 11.1 short-term development series + With "-d mysql": + - 8.0 maintained until 2026-04 (default) LTS + - 8.1 unmaintained since 2023-10 + - 8.2 unmaintained since 2024-01 + - 8.3 maintained until 2024-04 + With "-d postgres": + - 10 unmaintained since 2022-11-10 (default) + - 11 unmaintained since 2023-11-09 + - 12 maintained until 2024-11-14 + - 13 maintained until 2025-11-13 + - 14 maintained until 2026-11-12 + - 15 maintained until 2027-11-11 + - 16 maintained until 2028-11-09 + + -c + Hack functional tests into #numberOfChunks pieces and run tests of #chunk. + Example -c 3/13 + + -p <8.2|8.3> + Specifies the PHP minor version to be used + - 8.2 (default): use PHP 8.2 + - 8.3: use PHP 8.3 + + -e "" (DEPRECATED). + Additional options to send to phpunit. + DEPRECATED - pass arguments after the "--" separator directly. + + -x + Send information to host instance for test break points. This is especially + useful if a local PhpStorm instance is listening on default xdebug port 9003. A different port + can be selected with -y + + -y + Send xdebug information to a different port than default 9003 if an IDE like PhpStorm + is not listening on default port. + + -h + Show this help. + +Examples: + # Run functional tests on postgres with xdebug, php 8.3 and execute a restricted set of tests + $THIS_SCRIPT_NAME -x -p 8.3 -d postgres -i 12 -- --filter PageActionsTest + + # Run functional tests on postgres 11 + $THIS_SCRIPT_NAME -d postgres -i 11 + +EOF +} + +# Test if docker exists, else exit out with error +if ! type "docker" >/dev/null 2>&1 && ! type "podman" >/dev/null 2>&1; then + echo "This script relies on docker or podman. Please install" >&2 + exit 1 +fi + +# Go to the directory this script is located, so everything else is relative +# to this dir, no matter from where this script is called. Later, go to the +# project root. +THIS_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" +cd "$THIS_SCRIPT_DIR" || exit 1 + +RUNTESTS_ENV="${RUNTESTS_ENV:=../../runTests.env}" +if [ -f "${RUNTESTS_ENV}" ] ; then + echo "Using runTests config from: ${RUNTESTS_ENV}" + source "${RUNTESTS_ENV}" + while IFS='=' read -r name value ; do + if [[ $name == RUNTESTS_DIR_* ]]; then + if [[ $value != */ ]]; then + export "$name=$value/" + fi + fi + done < <(env) +else + # Default files when runTests.env is missing, using TYPO3 core configuration: + RUNTESTS_DIR_ROOT="${RUNTESTS_DIR_ROOT:=../../}" + RUNTESTS_DIR_BUILDER="${RUNTESTS_DIR_BUILDER:=Build/Scripts/}" + RUNTESTS_DIR_BIN="${RUNTESTS_DIR_BIN:=vendor/bin/}" + RUNTESTS_DIR_TESTTEMP="${RUNTESTS_DIR_TESTTEMP:=typo3temp/var/tests/}" + RUNTESTS_DIR_CACHE="${RUNTESTS_DIR_TESTTEMP:=.cache/}" + RUNTESTS_PHPUNIT_FILE_FUNCTIONALTEST="${RUNTESTS_PHPUNIT_FILE_FUNCTIONALTEST:=Build/phpunit/FunctionalTests.xml}" + RUNTESTS_DIR_PHPUNIT_FUNCTIONAL="${RUNTESTS_DIR_PHPUNIT_FUNCTIONAL:=Build/phpunit/}" +fi + +# Now go into the actual "base" directory. +cd "$RUNTESTS_DIR_ROOT" || exit 1 +CORE_ROOT="${PWD}" + +# Default variables +TEST_SUITE="functional" +DBMS="sqlite" +DBMS_VERSION="" +PHP_VERSION="8.2" +PHP_XDEBUG_ON=0 +PHP_XDEBUG_PORT=9003 +EXTRA_TEST_OPTIONS="" +DATABASE_DRIVER="" +CHUNKS=0 +THISCHUNK=0 +CONTAINER_BIN="" +COMPOSER_ROOT_VERSION="13.2.x-dev" +CONTAINER_SHELL="/bin/sh" +CONTAINER_INTERACTIVE="-it --init" +HOST_UID=$(id -u) +HOST_PID=$(id -g) +USERSET="" +SUFFIX=$(echo $RANDOM) +NETWORK="typo3-core-${SUFFIX}" +CI_PARAMS="${CI_PARAMS:-}" +CONTAINER_HOST="host.docker.internal" +THIS_SCRIPT_NAME="${RUNTESTS_DIR_BUILDER}runTests.sh" +HELP_MESSAGE="Use \"${THIS_SCRIPT_NAME} -h\" to display help and valid options" + +# Option parsing +OPTIND=1 +INVALID_OPTIONS=() +while getopts ":a:b:c:d:i:p:e:xy:h" OPT; do + case ${OPT} in + b) + if ! [[ ${OPTARG} =~ ^(docker|podman)$ ]]; then + INVALID_OPTIONS+=("${OPTARG}") + fi + CONTAINER_BIN=${OPTARG} + ;; + a) + DATABASE_DRIVER=${OPTARG} + ;; + c) + if ! [[ ${OPTARG} =~ ^([0-9]+\/[0-9]+)$ ]]; then + INVALID_OPTIONS+=("${OPTARG}") + else + THISCHUNK=$(echo "${OPTARG}" | cut -d '/' -f1) + CHUNKS=$(echo "${OPTARG}" | cut -d '/' -f2) + fi + ;; + d) + DBMS=${OPTARG} + ;; + i) + DBMS_VERSION=${OPTARG} + ;; + p) + PHP_VERSION=${OPTARG} + if ! [[ ${PHP_VERSION} =~ ^(8.2|8.3)$ ]]; then + INVALID_OPTIONS+=("${OPTARG}") + fi + ;; + e) + EXTRA_TEST_OPTIONS=${OPTARG} + ;; + x) + PHP_XDEBUG_ON=1 + ;; + y) + PHP_XDEBUG_PORT=${OPTARG} + ;; + h) + loadHelp + echo "${HELP}" + exit 0 + ;; + \") + INVALID_OPTIONS+=("${OPTARG}") + ;; + :) + INVALID_OPTIONS+=("${OPTARG}") + ;; + esac +done + +if [ ${#INVALID_OPTIONS[@]} -ne 0 ]; then + echo "Invalid option(s):" >&2 + for I in "${INVALID_OPTIONS[@]}"; do + echo "-${I}" >&2 + done + echo >&2 + echo "${HELP_MESSAGE}" >&2 + exit 1 +fi + +handleDbmsOptions + +if [ "${CI}" == "true" ]; then + CONTAINER_INTERACTIVE="" +fi + +if [[ -z "${CONTAINER_BIN}" ]]; then + if type "podman" >/dev/null 2>&1; then + CONTAINER_BIN="podman" + elif type "docker" >/dev/null 2>&1; then + CONTAINER_BIN="docker" + fi +fi + +if [ $(uname) != "Darwin" ] && [ ${CONTAINER_BIN} = "docker" ]; then + USERSET="--user $HOST_UID" +fi + +if ! type ${CONTAINER_BIN} >/dev/null 2>&1; then + echo "Selected container environment \"${CONTAINER_BIN}\" not found. Please install or use -b option to select one." >&2 + exit 1 +fi + +IMAGE_PHP="ghcr.io/typo3/core-testing-$(echo "php${PHP_VERSION}" | sed -e 's/\.//'):$(getPhpImageVersion $PHP_VERSION)" +IMAGE_ALPINE="docker.io/alpine:3.8" +IMAGE_REDIS="docker.io/redis:4-alpine" +IMAGE_MEMCACHED="docker.io/memcached:1.5-alpine" +IMAGE_MARIADB="docker.io/mariadb:${DBMS_VERSION}" +IMAGE_MYSQL="docker.io/mysql:${DBMS_VERSION}" +IMAGE_POSTGRES="docker.io/postgres:${DBMS_VERSION}-alpine" + +shift $((OPTIND - 1)) + +mkdir -p .cache + +${CONTAINER_BIN} network create ${NETWORK} >/dev/null + +if [ ${CONTAINER_BIN} = "docker" ]; then + CONTAINER_COMMON_PARAMS="${CONTAINER_INTERACTIVE} --rm --network ${NETWORK} --add-host "${CONTAINER_HOST}:host-gateway" ${USERSET} -v ${CORE_ROOT}:${CORE_ROOT} -w ${CORE_ROOT}" +else + CONTAINER_HOST="host.containers.internal" + CONTAINER_COMMON_PARAMS="${CONTAINER_INTERACTIVE} ${CI_PARAMS} --rm --network ${NETWORK} -v ${CORE_ROOT}:${CORE_ROOT} -w ${CORE_ROOT}" +fi + +if [ ${PHP_XDEBUG_ON} -eq 0 ]; then + XDEBUG_MODE="-e XDEBUG_MODE=off" + XDEBUG_CONFIG=" " +else + XDEBUG_MODE="-e XDEBUG_MODE=debug -e XDEBUG_TRIGGER=foo" + XDEBUG_CONFIG="client_port=${PHP_XDEBUG_PORT} client_host=${CONTAINER_HOST}" +fi + +# Suite execution: functional +if [ "${CHUNKS}" -gt 0 ]; then + ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name func-splitter-${SUFFIX} -e CORE_ROOT="${CORE_ROOT}" -e RUNTESTS_DIRS_RPOJECT="${RUNTESTS_DIRS_RPOJECT}" ${IMAGE_PHP} php -dxdebug.mode=off ${RUNTESTS_DIR_BUILDER}splitFunctionalTests.php -v ${CHUNKS} + COMMAND=(${RUNTESTS_DIR_BIN}phpunit -c ${RUNTESTS_DIR_PHPUNIT_FUNCTIONAL}FunctionalTests-Job-${THISCHUNK}.xml --exclude-group not-${DBMS} ${EXTRA_TEST_OPTIONS} "$@") +else + COMMAND=(${RUNTESTS_DIR_BIN}phpunit -c ${RUNTESTS_PHPUNIT_FILE_FUNCTIONALTEST} --exclude-group not-${DBMS} ${EXTRA_TEST_OPTIONS} "$@") +fi +${CONTAINER_BIN} run --rm ${CI_PARAMS} --name redis-func-${SUFFIX} --network ${NETWORK} -d ${IMAGE_REDIS} >/dev/null +${CONTAINER_BIN} run --rm ${CI_PARAMS} --name memcached-func-${SUFFIX} --network ${NETWORK} -d ${IMAGE_MEMCACHED} >/dev/null +waitFor redis-func-${SUFFIX} 6379 +waitFor memcached-func-${SUFFIX} 11211 +CONTAINER_COMMON_PARAMS="${CONTAINER_COMMON_PARAMS} -e typo3TestingRedisHost=redis-func-${SUFFIX} -e typo3TestingMemcachedHost=memcached-func-${SUFFIX}" +case ${DBMS} in + mariadb) + echo "Using driver: ${DATABASE_DRIVER}" + ${CONTAINER_BIN} run --rm ${CI_PARAMS} --name mariadb-func-${SUFFIX} --network ${NETWORK} -d -e MYSQL_ROOT_PASSWORD=funcp --tmpfs /var/lib/mysql/:rw,noexec,nosuid ${IMAGE_MARIADB} >/dev/null + waitFor mariadb-func-${SUFFIX} 3306 + CONTAINERPARAMS="-e typo3DatabaseDriver=${DATABASE_DRIVER} -e typo3DatabaseName=func_test -e typo3DatabaseUsername=root -e typo3DatabaseHost=mariadb-func-${SUFFIX} -e typo3DatabasePassword=funcp" + ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name functional-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${CONTAINERPARAMS} ${IMAGE_PHP} "${COMMAND[@]}" + SUITE_EXIT_CODE=$? + ;; + mysql) + echo "Using driver: ${DATABASE_DRIVER}" + ${CONTAINER_BIN} run --rm ${CI_PARAMS} --name mysql-func-${SUFFIX} --network ${NETWORK} -d -e MYSQL_ROOT_PASSWORD=funcp --tmpfs /var/lib/mysql/:rw,noexec,nosuid ${IMAGE_MYSQL} >/dev/null + waitFor mysql-func-${SUFFIX} 3306 + CONTAINERPARAMS="-e typo3DatabaseDriver=${DATABASE_DRIVER} -e typo3DatabaseName=func_test -e typo3DatabaseUsername=root -e typo3DatabaseHost=mysql-func-${SUFFIX} -e typo3DatabasePassword=funcp" + ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name functional-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${CONTAINERPARAMS} ${IMAGE_PHP} "${COMMAND[@]}" + SUITE_EXIT_CODE=$? + ;; + postgres) + ${CONTAINER_BIN} run --rm ${CI_PARAMS} --name postgres-func-${SUFFIX} --network ${NETWORK} -d -e POSTGRES_PASSWORD=funcp -e POSTGRES_USER=funcu --tmpfs /var/lib/postgresql/data:rw,noexec,nosuid ${IMAGE_POSTGRES} >/dev/null + waitFor postgres-func-${SUFFIX} 5432 + CONTAINERPARAMS="-e typo3DatabaseDriver=pdo_pgsql -e typo3DatabaseName=bamboo -e typo3DatabaseUsername=funcu -e typo3DatabaseHost=postgres-func-${SUFFIX} -e typo3DatabasePassword=funcp" + ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name functional-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${CONTAINERPARAMS} ${IMAGE_PHP} "${COMMAND[@]}" + SUITE_EXIT_CODE=$? + ;; + sqlite) + # create sqlite tmpfs mount (temp)functional-sqlite-dbs/ to avoid permission issues + mkdir -p "${CORE_ROOT}/${RUNTESTS_DIR_TESTTEMP}functional-sqlite-dbs/" + CONTAINERPARAMS="-e typo3DatabaseDriver=pdo_sqlite --tmpfs ${CORE_ROOT}/${RUNTESTS_DIR_TESTTEMP}functional-sqlite-dbs/:rw,noexec,nosuid" + ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name functional-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${CONTAINERPARAMS} ${IMAGE_PHP} "${COMMAND[@]}" + SUITE_EXIT_CODE=$? + ;; +esac + +cleanUp + +# Print summary +echo "" >&2 +echo "###########################################################################" >&2 +echo "Result of functional tests" >&2 +echo "Container runtime: ${CONTAINER_BIN}" >&2 +echo "PHP: ${PHP_VERSION}" >&2 +case "${DBMS}" in + mariadb|mysql|postgres) + echo "DBMS: ${DBMS} version ${DBMS_VERSION} driver ${DATABASE_DRIVER}" >&2 + ;; + sqlite) + echo "DBMS: ${DBMS}" >&2 + ;; +esac +if [[ -n ${EXTRA_TEST_OPTIONS} ]]; then + echo " Note: Using -e is deprecated. Simply add the options at the end of the command." + echo " Instead of: ${THIS_SCRIPT_NAME} -e '${EXTRA_TEST_OPTIONS}' $@" + echo " use: ${THIS_SCRIPT_NAME} -- ${EXTRA_TEST_OPTIONS} $@" +fi +if [[ ${SUITE_EXIT_CODE} -eq 0 ]]; then + echo "SUCCESS" >&2 +else + echo "FAILURE" >&2 +fi +echo "###########################################################################" >&2 +echo "" >&2 + +exit $SUITE_EXIT_CODE \ No newline at end of file diff --git a/Build/php-cs-fixer/php-cs-fixer.php b/Build/php-cs-fixer/php-cs-fixer.php new file mode 100644 index 0000000..8a97445 --- /dev/null +++ b/Build/php-cs-fixer/php-cs-fixer.php @@ -0,0 +1,67 @@ +setFinder( + (new Finder())->in(__DIR__ . '/../../') + ) + ->setRiskyAllowed(true) + ->setRules([ + '@DoctrineAnnotation' => true, + '@PER-CS' => true, + 'array_syntax' => ['syntax' => 'short'], + 'blank_line_after_opening_tag' => true, + 'cast_spaces' => ['space' => 'none'], + 'compact_nullable_type_declaration' => true, + 'concat_space' => ['spacing' => 'one'], + 'declare_equal_normalize' => ['space' => 'none'], + 'dir_constant' => true, + 'type_declaration_spaces' => true, + 'lowercase_cast' => true, + 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], + 'modernize_types_casting' => true, + 'native_function_casing' => true, + 'new_with_parentheses' => true, + 'no_alias_functions' => true, + 'no_blank_lines_after_phpdoc' => true, + 'no_empty_phpdoc' => true, + 'no_empty_statement' => true, + 'no_extra_blank_lines' => true, + 'no_leading_import_slash' => true, + 'no_leading_namespace_whitespace' => true, + 'no_null_property_initialization' => true, + 'no_short_bool_cast' => true, + 'no_singleline_whitespace_before_semicolons' => true, + 'no_superfluous_elseif' => true, + 'no_trailing_comma_in_singleline' => true, + 'no_unneeded_control_parentheses' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_nullsafe_operator' => true, + 'no_whitespace_in_blank_line' => true, + 'ordered_imports' => true, + 'php_unit_construct' => ['assertions' => ['assertEquals', 'assertSame', 'assertNotEquals', 'assertNotSame']], + 'php_unit_mock_short_will_return' => true, + 'php_unit_test_case_static_method_calls' => ['call_type' => 'self'], + 'phpdoc_no_access' => true, + 'phpdoc_no_empty_return' => true, + 'phpdoc_no_package' => true, + 'phpdoc_scalar' => true, + 'phpdoc_trim' => true, + 'phpdoc_types' => true, + 'phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none'], + 'php_unit_data_provider_static' => [ + 'force' => true, + ], + 'return_type_declaration' => ['space_before' => 'none'], + 'single_quote' => true, + 'single_line_comment_style' => ['comment_types' => ['hash']], + 'single_trait_insert_per_statement' => true, + 'trailing_comma_in_multiline' => ['elements' => ['arrays']], + 'whitespace_after_comma_in_array' => ['ensure_single_space' => true], + 'yoda_style' => ['equal' => false, 'identical' => false, 'less_and_greater' => false], + ]); diff --git a/Build/phpunit/FunctionalTests.xml b/Build/phpunit/FunctionalTests.xml new file mode 100644 index 0000000..a67642a --- /dev/null +++ b/Build/phpunit/FunctionalTests.xml @@ -0,0 +1,30 @@ + + + + + + + ../../Tests/Functional/ + + + + + ../../Classes/ + + + \ No newline at end of file diff --git a/Build/phpunit/FunctionalTestsBootstrap.php b/Build/phpunit/FunctionalTestsBootstrap.php new file mode 100644 index 0000000..b3509f2 --- /dev/null +++ b/Build/phpunit/FunctionalTestsBootstrap.php @@ -0,0 +1,33 @@ +defineOriginalRootPath(); + $testbase->createDirectory(ORIGINAL_ROOT . 'typo3temp/var/tests'); + $testbase->createDirectory(ORIGINAL_ROOT . 'typo3temp/var/transient'); +})(); diff --git a/Build/phpunit/UnitTests.xml b/Build/phpunit/UnitTests.xml new file mode 100644 index 0000000..dfc4570 --- /dev/null +++ b/Build/phpunit/UnitTests.xml @@ -0,0 +1,28 @@ + + + + ../../Tests/Unit/ + + + + + ../../Classes/ + + + \ No newline at end of file diff --git a/Build/phpunit/UnitTestsBootstrap.php b/Build/phpunit/UnitTestsBootstrap.php new file mode 100644 index 0000000..acddbff --- /dev/null +++ b/Build/phpunit/UnitTestsBootstrap.php @@ -0,0 +1,90 @@ +getWebRoot(), '/')); + } + if (!getenv('TYPO3_PATH_WEB')) { + putenv('TYPO3_PATH_WEB=' . rtrim($testbase->getWebRoot(), '/')); + } + + $testbase->defineSitePath(); + + // We can use the "typo3/cms-composer-installers" constant "TYPO3_COMPOSER_MODE" to determine composer mode. + // This should be always true except for TYPO3 mono repository. + $composerMode = defined('TYPO3_COMPOSER_MODE') && TYPO3_COMPOSER_MODE === true; + + // @todo: Remove else branch when dropping support for v12 + $hasConsolidatedHttpEntryPoint = class_exists(CoreHttpApplication::class); + if ($hasConsolidatedHttpEntryPoint) { + \TYPO3\TestingFramework\Core\SystemEnvironmentBuilder::run(0, \TYPO3\CMS\Core\Core\SystemEnvironmentBuilder::REQUESTTYPE_CLI, $composerMode); + } else { + $requestType = \TYPO3\CMS\Core\Core\SystemEnvironmentBuilder::REQUESTTYPE_BE | \TYPO3\CMS\Core\Core\SystemEnvironmentBuilder::REQUESTTYPE_CLI; + \TYPO3\TestingFramework\Core\SystemEnvironmentBuilder::run(0, $requestType, $composerMode); + } + + $testbase->createDirectory(\TYPO3\CMS\Core\Core\Environment::getPublicPath() . '/typo3conf/ext'); + $testbase->createDirectory(\TYPO3\CMS\Core\Core\Environment::getPublicPath() . '/typo3temp/assets'); + $testbase->createDirectory(\TYPO3\CMS\Core\Core\Environment::getPublicPath() . '/typo3temp/var/tests'); + $testbase->createDirectory(\TYPO3\CMS\Core\Core\Environment::getPublicPath() . '/typo3temp/var/transient'); + + // Retrieve an instance of class loader and inject to core bootstrap + $classLoader = require $testbase->getPackagesPath() . '/autoload.php'; + \TYPO3\CMS\Core\Core\Bootstrap::initializeClassLoader($classLoader); + + // Initialize default TYPO3_CONF_VARS + $configurationManager = new \TYPO3\CMS\Core\Configuration\ConfigurationManager(); + $GLOBALS['TYPO3_CONF_VARS'] = $configurationManager->getDefaultConfiguration(); + + $cache = new \TYPO3\CMS\Core\Cache\Frontend\PhpFrontend( + 'core', + new \TYPO3\CMS\Core\Cache\Backend\NullBackend('production', []) + ); + $packageManager = \TYPO3\CMS\Core\Core\Bootstrap::createPackageManager( + \TYPO3\CMS\Core\Package\UnitTestPackageManager::class, + \TYPO3\CMS\Core\Core\Bootstrap::createPackageCache($cache) + ); + + \TYPO3\CMS\Core\Utility\GeneralUtility::setSingletonInstance(\TYPO3\CMS\Core\Package\PackageManager::class, $packageManager); + \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::setPackageManager($packageManager); + + $testbase->dumpClassLoadingInformation(); + + \TYPO3\CMS\Core\Utility\GeneralUtility::purgeInstances(); +})(); diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c7a446..208b1b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog All notable changes to this extension will be documented in this file. +## [13.4.10] - 2025-10-21 +### Added +- Add functional and unit tests. +- Add GitHub Actions workflow for CI. + ## [13.4.9] - 2025-09-25 ### Added - Rename `ClearSysLogCommand` to `SeperateSyshistoryFromSyslogCommand`. @@ -39,4 +44,4 @@ All notable changes to this extension will be documented in this file. ## [13.4.1] - 2025-06-20 ### Added -- Added a CLI command (tmupgrade:run) that allows executing upgrade wizards from a specific version, with an option to exclude selected wizards. \ No newline at end of file +- Added a CLI command (tmupgrade:run) that allows executing upgrade wizards from a specific version, with an option to exclude selected wizards. diff --git a/Classes/Command/ExportCtypeListTypeCommand.php b/Classes/Command/ExportCtypeListTypeCommand.php index 8c29384..25d28eb 100644 --- a/Classes/Command/ExportCtypeListTypeCommand.php +++ b/Classes/Command/ExportCtypeListTypeCommand.php @@ -32,7 +32,7 @@ final class ExportCtypeListTypeCommand extends Command protected const FIELDS = [ 'CType', 'list_type', - 'pids' + 'pids', ]; protected const FILE_NAME = 'export.csv'; protected const FILE_TYPE_JSON = 'json'; @@ -59,7 +59,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $listTypeAndCTypeArray = $this->getListTypeAndCTypeArray(); $fileType = $input->getOption('fileType'); - if($fileType && strtolower($fileType) == self::FILE_TYPE_JSON) { + if ($fileType && strtolower($fileType) == self::FILE_TYPE_JSON) { $this->exportJson($listTypeAndCTypeArray); } else { $contents = $this->export($listTypeAndCTypeArray, self::FIELDS); @@ -91,14 +91,14 @@ private function export($list, $fields): string private function exportJson($list): void { foreach ($list as $row) { - if($row['CType'] == 'list' || $row['list_type']) { + if ($row['CType'] == 'list' || $row['list_type']) { $tmp[] = $row['list_type'] . ':' . $row['list_type']; } } $this->io->info('Here is the configuration for List Type to CType Mapping.'); $this->io->text(implode(',', $tmp)); - echo(PHP_EOL); + echo PHP_EOL; } private function renderHeader($fields): array @@ -109,9 +109,9 @@ private function renderHeader($fields): array private function renderContent($row, $fields): array { $data = []; - + foreach ($fields as $field) { - if($field == 'pids') { + if ($field == 'pids') { $data[] = $row['pids']; } else { $data[] = $row[$field]; @@ -162,4 +162,4 @@ private function logError(string $message): void $this->io->error($message); $this->logger->error($message); } -} \ No newline at end of file +} diff --git a/Classes/Command/FixDatabaseErrorsCommand.php b/Classes/Command/FixDatabaseErrorsCommand.php index a0aa60b..8577e8e 100644 --- a/Classes/Command/FixDatabaseErrorsCommand.php +++ b/Classes/Command/FixDatabaseErrorsCommand.php @@ -51,7 +51,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int foreach ($records as $fields) { $sortingFields = $this->getSortingFields($table, $fields); $this->deleteRecords($table, $fields); - $this->insertRecord($table, array_merge($fields,$sortingFields)); + $this->insertRecord($table, array_merge($fields, $sortingFields)); $progressBar->advance(); } $progressBar->finish(); @@ -72,7 +72,7 @@ private function getSortingFields(string $table, array $fields): array $where[] = $queryBuilder->expr()->eq($field, $queryBuilder->createNamedParameter($value)); } $result = $queryBuilder - ->select('sorting','sorting_foreign') + ->select('sorting', 'sorting_foreign') ->from($table) ->where(...$where) ->executeQuery(); @@ -131,7 +131,7 @@ private function getAffectedMmTables(): array ->having('COUNT(fieldname) > 1') ->executeQuery(); } elseif ($this->tableHasMmMatchFields($mmTable) === false) { - if(in_array($mmTable, ['sys_dmail_ttaddress_category_mm','sys_dmail_feuser_category_mm','sys_dmail_group_category_mm'])){ + if (in_array($mmTable, ['sys_dmail_ttaddress_category_mm', 'sys_dmail_feuser_category_mm', 'sys_dmail_group_category_mm'])) { $queryBuilder = $this->connectionPool->getQueryBuilderForTable($mmTable); $result = $queryBuilder ->select('uid_local', 'uid_foreign', 'tablenames') @@ -140,8 +140,7 @@ private function getAffectedMmTables(): array ->having('COUNT(uid_local) > 1') ->having('COUNT(uid_foreign) > 1') ->executeQuery(); - } - else{ + } else { $queryBuilder = $this->connectionPool->getQueryBuilderForTable($mmTable); $result = $queryBuilder ->select('uid_local', 'uid_foreign') @@ -218,4 +217,4 @@ private function logError(string $message): void $this->io->error($message); $this->logger->error($message); } -} \ No newline at end of file +} diff --git a/Classes/Command/RunSqlScriptCommand.php b/Classes/Command/RunSqlScriptCommand.php index daf8e5f..e7c9288 100644 --- a/Classes/Command/RunSqlScriptCommand.php +++ b/Classes/Command/RunSqlScriptCommand.php @@ -6,13 +6,13 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputOption; -use TYPO3\CMS\Core\Core\Environment; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Toumoro\TmMigration\Service\SQLMigrationService; +use TYPO3\CMS\Core\Core\Environment; use TYPO3\CMS\Core\Utility\GeneralUtility; -use Toumoro\TmMigration\Service\SqlMigrationService; /** * Class RunSqlScriptCommand @@ -49,12 +49,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $fileNames = $this->getFileNames($input); - if(!empty($fileNames)) { + if (!empty($fileNames)) { foreach ($fileNames as $fileName) { $content = file_get_contents($fileName); // check if the file is not empty - if($content) { + if ($content) { $queries = array_filter( array_map( @@ -63,18 +63,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int ) ); - $sqlMigrationService = GeneralUtility::makeInstance(SqlMigrationService::class); - $condition = $sqlMigrationService->migrate($queries) > 0; - if($condition) { + $SQLMigrationService = GeneralUtility::makeInstance(SQLMigrationService::class); + $condition = $SQLMigrationService->migrate($queries) > 0; + if ($condition) { $success = Command::SUCCESS; $this->io->info($fileName . ' executed with no errors.'); - } - else { + } else { $success = Command::FAILURE; $this->io->info($fileName . ' failed to be executed.'); - } - } - else { + } + } else { $success = Command::FAILURE; $this->io->info($fileName . ' is empty.'); } @@ -83,7 +81,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $success = Command::FAILURE; $this->io->info('no sql file found !'); } - + return $success; } @@ -91,22 +89,26 @@ private function getFileNames($input): array { $file = $input->getOption('file'); - if(file_exists($file) && $file) { + if ($file && file_exists($file)) { + + // If file path is already absolute, use it as-is + if (str_starts_with($file, '/')) { + return [$file]; + } + $fileName = Environment::getProjectPath() . '/' . $file; - - return [ - $fileName ?? self::FILE_NAME - ]; + + return [$fileName ?? self::FILE_NAME]; } $directory = $input->getOption('directory'); - - if(file_exists($directory) && $directory) { + + if ($directory && file_exists($directory)) { $files = glob($directory . '/*.sql', GLOB_MARK); - + return $files; } return []; } -} \ No newline at end of file +} diff --git a/Classes/Command/SeperateSyshistoryFromSyslogCommand.php b/Classes/Command/SeperateSyshistoryFromSyslogCommand.php index 5e88eb6..6021321 100644 --- a/Classes/Command/SeperateSyshistoryFromSyslogCommand.php +++ b/Classes/Command/SeperateSyshistoryFromSyslogCommand.php @@ -6,12 +6,12 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Toumoro\TmMigration\Service\SQLMigrationService; use TYPO3\CMS\Core\Utility\GeneralUtility; -use Toumoro\TmMigration\Service\SqlMigrationService; /** * Class SeperateSyshistoryFromSyslogCommand @@ -38,30 +38,29 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io = new SymfonyStyle($input, $output); $limit = $input->getOption('limit'); $days = $input->getOption('days'); - $timestamp = $days?strtotime('-'.$days.' days'):''; + $timestamp = $days ? strtotime('-' . $days . ' days') : ''; - $sqlMigrationService = GeneralUtility::makeInstance(SqlMigrationService::class); - $statement = " DELETE FROM sys_log WHERE NOT EXISTS + $SQLMigrationService = GeneralUtility::makeInstance(SQLMigrationService::class); + $statement = ' DELETE FROM sys_log WHERE NOT EXISTS (SELECT * FROM sys_history WHERE sys_history.sys_log_uid=sys_log.uid) - AND recuid=0 "; - if($timestamp){ - $statement .= " AND tstamp < ".$timestamp; + AND recuid=0 '; + if ($timestamp) { + $statement .= ' AND tstamp < ' . $timestamp; } - if($limit){ - $statement .= " LIMIT ".$limit; + if ($limit) { + $statement .= ' LIMIT ' . $limit; } - $condition = $sqlMigrationService->migrate([$statement]) > 0; - if($condition){ + $condition = $SQLMigrationService->migrate([$statement]) > 0; + if ($condition) { $success = Command::SUCCESS; $this->io->info('seperate sys_history from sys_log command executed with no errors.'); - } - else{ + } else { $success = Command::FAILURE; $this->io->info('seperate sys_history from sys_log command failed to be executed.'); } return $success; } -} \ No newline at end of file +} diff --git a/Classes/Command/UpgradeWizardRunCommand.php b/Classes/Command/UpgradeWizardRunCommand.php index 59fcdd3..0e79b97 100644 --- a/Classes/Command/UpgradeWizardRunCommand.php +++ b/Classes/Command/UpgradeWizardRunCommand.php @@ -4,6 +4,7 @@ namespace Toumoro\TmMigration\Command; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; @@ -11,6 +12,8 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Style\SymfonyStyle; +use Toumoro\TmMigration\Utility\ConfigurationUtility; +use Toumoro\TmMigration\Utility\UpgardeWizardsMappingUtility; use TYPO3\CMS\Core\Authentication\CommandLineUserAuthentication; use TYPO3\CMS\Core\Configuration\Exception\SettingsWriteException; use TYPO3\CMS\Core\Core\Bootstrap; @@ -29,13 +32,10 @@ use TYPO3\CMS\Install\Updates\PrerequisiteCollection; use TYPO3\CMS\Install\Updates\RepeatableInterface; use TYPO3\CMS\Install\Updates\UpgradeWizardInterface; -use Toumoro\TmMigration\Utility\ConfigurationUtility; -use Toumoro\TmMigration\Utility\UpgardeWizardsMappingUtility; -use Symfony\Component\Console\Attribute\AsCommand; /** * Upgrade wizard command for running wizards - * + * * Class UpgradeWizardRunCommand */ #[AsCommand( @@ -110,9 +110,9 @@ protected function configure() 'wizardName', InputArgument::OPTIONAL )->setHelp( - 'This command allows running upgrade wizards on CLI. To run a single wizard add the ' . - 'identifier of the wizard as argument. The identifier of the wizard is the name it is ' . - 'registered with in ext_localconf.' + 'This command allows running upgrade wizards on CLI. To run a single wizard add the ' + . 'identifier of the wizard as argument. The identifier of the wizard is the name it is ' + . 'registered with in ext_localconf.' ); } @@ -214,9 +214,9 @@ protected function handlePrerequisites(array $instances): bool $result = $prerequisite->ensure(); if ($result === false) { $this->output->error( - 'Error running ' . - $prerequisite->getTitle() . - '. Please ensure this prerequisite manually and try again.' + 'Error running ' + . $prerequisite->getTitle() + . '. Please ensure this prerequisite manually and try again.' ); break; } @@ -323,7 +323,7 @@ private function getUpgradeWizardIdentifiers(): array $excludedWizards = []; - if($fromVersion) { + if ($fromVersion) { foreach ($upgradeWizardsMapping as $version => $wizards) { if (version_compare((string)$version, $fromVersion, '<')) { $excludedWizards = array_merge($excludedWizards, $wizards); @@ -331,15 +331,15 @@ private function getUpgradeWizardIdentifiers(): array } } - if(isset($excludedIdentifiers)) { + if (isset($excludedIdentifiers)) { try { - foreach($excludedIdentifiers as $identifier) { - if($this->isValid($identifier)) { + foreach ($excludedIdentifiers as $identifier) { + if ($this->isValid($identifier)) { array_push($excludedWizards, $identifier); } } - - } catch(\Exception $e) { + + } catch (\Exception $e) { throw new \Exception($e->getMessage(), 1533931000); } } diff --git a/Classes/Service/SqlMigrationService.php b/Classes/Service/SQLMigrationService.php similarity index 94% rename from Classes/Service/SqlMigrationService.php rename to Classes/Service/SQLMigrationService.php index 1cdbb51..00324ad 100644 --- a/Classes/Service/SqlMigrationService.php +++ b/Classes/Service/SQLMigrationService.php @@ -1,4 +1,5 @@ getListTypeToCTypeMapping() !== [] && - $this->columnsExistInContentTable() && - $this->hasContentElementsToUpdate() + $this->getListTypeToCTypeMapping() !== [] + && $this->columnsExistInContentTable() + && $this->hasContentElementsToUpdate() ) || ( - $this->getListTypeToCTypeMapping() !== [] && - $this->columnsExistInBackendUserGroupsTable() + $this->getListTypeToCTypeMapping() !== [] + && $this->columnsExistInBackendUserGroupsTable() && $this->hasNoLegacyBackendGroupsExplicitAllowDenyConfiguration() && $this->hasBackendUserGroupsToUpdate() ); @@ -84,14 +84,14 @@ public function updateNecessary(): bool public function executeUpdate(): bool { - if ($this->getListTypeToCTypeMapping() !== [] && - $this->columnsExistInContentTable() && - $this->hasContentElementsToUpdate() + if ($this->getListTypeToCTypeMapping() !== [] + && $this->columnsExistInContentTable() + && $this->hasContentElementsToUpdate() ) { $this->updateContentElements(); } - if ($this->getListTypeToCTypeMapping() !== [] && - $this->columnsExistInBackendUserGroupsTable() + if ($this->getListTypeToCTypeMapping() !== [] + && $this->columnsExistInBackendUserGroupsTable() && $this->hasNoLegacyBackendGroupsExplicitAllowDenyConfiguration() && $this->hasBackendUserGroupsToUpdate() ) { diff --git a/Classes/Upgrades/CTypeToListTypeUpgradeWizard.php b/Classes/Upgrades/CTypeToListTypeUpgradeWizard.php index a58d919..539c9a8 100644 --- a/Classes/Upgrades/CTypeToListTypeUpgradeWizard.php +++ b/Classes/Upgrades/CTypeToListTypeUpgradeWizard.php @@ -6,7 +6,7 @@ use Toumoro\TmMigration\Updates\AbstractListTypeToCTypeUpdate; use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; -use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Install\Attribute\UpgradeWizard; /** @@ -15,8 +15,15 @@ #[UpgradeWizard('tmMigration_cTypeToListTypeUpgradeWizard')] final class CTypeToListTypeUpgradeWizard extends AbstractListTypeToCTypeUpdate { - private const MAPPING_ARRAY = 'cTypeToListTypeMappingArray'; + private const DEFAULT_MAPPING_ARRAY = [ 'pi_plugin1' => 'new_content_element1' ]; + + public function __construct( + private readonly ConnectionPool $connectionPool, + private readonly ExtensionConfiguration $extensionConfiguration + ) { + parent::__construct($this->connectionPool); + } public function getTitle(): string { @@ -41,13 +48,10 @@ public function getDescription(): string * @return array */ protected function getListTypeToCTypeMapping(): array - { - /** @var ExtensionConfiguration $extensionConfiguration */ - $extensionConfiguration = GeneralUtility::makeInstance(ExtensionConfiguration::class); - - $emConfiguration = $extensionConfiguration->get('tm_migration'); + { + $emConfiguration = $this->extensionConfiguration->get('tm_migration'); - if(isset($emConfiguration[self::MAPPING_ARRAY])) { + if (isset($emConfiguration[self::MAPPING_ARRAY])) { $tmpArray = explode(',', $emConfiguration[self::MAPPING_ARRAY]); foreach ($tmpArray as $item) { @@ -59,6 +63,6 @@ protected function getListTypeToCTypeMapping(): array return $cTypeListTypeMappingArray; } - return []; + return self::DEFAULT_MAPPING_ARRAY; } -} \ No newline at end of file +} diff --git a/Classes/Upgrades/FixRedirectsUpgraeWizard.php b/Classes/Upgrades/FixRedirectsUpgraeWizard.php index ec40fd6..e3c0952 100644 --- a/Classes/Upgrades/FixRedirectsUpgraeWizard.php +++ b/Classes/Upgrades/FixRedirectsUpgraeWizard.php @@ -1,17 +1,18 @@ getBrokenRedirects()); + return (bool)count($this->getBrokenRedirects()); } /** @@ -64,7 +65,7 @@ public function executeUpdate(): bool public function getPrerequisites(): array { return [ - DatabaseUpdatedPrerequisite::class + DatabaseUpdatedPrerequisite::class, ]; } @@ -72,12 +73,12 @@ private function updateRedirects(): bool { $redirects = $this->getBrokenRedirects(); - if(!empty($redirects)) { - + if (!empty($redirects)) { + foreach ($redirects as $row) { $sourcePath = $row['source_path']; - - if (strpos($sourcePath, '/') !== 0 + + if (strpos($sourcePath, '/') !== 0 && $row['is_regexp'] == 0 && strpos($sourcePath, 'https://') !== 0 && strpos($sourcePath, 'http://') !== 0 @@ -117,18 +118,18 @@ private function getBrokenRedirects(): array ->select('uid', 'source_path', 'target_statuscode', 'is_regexp') ->from(self::REDIRECT_TABLE) ->where( - $queryBuilder->expr()->notLike( + $queryBuilder->expr()->notLike( 'source_path', $queryBuilder->createNamedParameter('/%') ) ) ->orWhere( $queryBuilder->expr()->eq( - 'target_statuscode', + 'target_statuscode', $queryBuilder->createNamedParameter(0, ParameterType::INTEGER) ) ) ->executeQuery() ->fetchAllAssociative(); } -} \ No newline at end of file +} diff --git a/Classes/Upgrades/GridElementsToContainerUpgradeWizard.php b/Classes/Upgrades/GridElementsToContainerUpgradeWizard.php index 4d95072..f5b083a 100644 --- a/Classes/Upgrades/GridElementsToContainerUpgradeWizard.php +++ b/Classes/Upgrades/GridElementsToContainerUpgradeWizard.php @@ -20,6 +20,10 @@ class GridElementsToContainerUpgradeWizard implements UpgradeWizardInterface { private const TT_CONTENT_TABLE = 'tt_content'; + public function __construct( + private readonly ConnectionPool $connectionPool + ) {} + /** * @inheritDoc */ @@ -41,7 +45,7 @@ public function getDescription(): string */ public function updateNecessary(): bool { - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TT_CONTENT_TABLE); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TT_CONTENT_TABLE); return (bool)$queryBuilder->count('uid') ->from(self::TT_CONTENT_TABLE) ->where( @@ -57,7 +61,7 @@ public function updateNecessary(): bool public function getPrerequisites(): array { return [ - DatabaseUpdatedPrerequisite::class + DatabaseUpdatedPrerequisite::class, ]; } @@ -66,7 +70,7 @@ public function getPrerequisites(): array */ public function executeUpdate(): bool { - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TT_CONTENT_TABLE); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TT_CONTENT_TABLE); $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); $result = $queryBuilder->select('*') @@ -77,8 +81,7 @@ public function executeUpdate(): bool ->executeQuery() ->fetchAllAssociative(); - - if($result) { + if ($result) { foreach ($result as $gridElement) { $this->updateContainer($gridElement['uid'], $gridElement['tx_gridelements_backend_layout'], $gridElement['pi_flexform']); $this->updateChildren($gridElement['uid']); @@ -90,7 +93,7 @@ public function executeUpdate(): bool protected function updateContainer(int $uid, string $identifier, ?string $flexForm): void { - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TT_CONTENT_TABLE); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TT_CONTENT_TABLE); $queryBuilder->update(self::TT_CONTENT_TABLE) ->set('CType', $identifier) ->where( @@ -104,7 +107,7 @@ protected function updateContainer(int $uid, string $identifier, ?string $flexFo protected function updateChildren(int $uid): void { - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TT_CONTENT_TABLE); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TT_CONTENT_TABLE); $result = $queryBuilder->select('*') ->from(self::TT_CONTENT_TABLE) ->where( @@ -116,8 +119,7 @@ protected function updateChildren(int $uid): void ->executeQuery() ->fetchAllAssociative(); - - if($result) { + if ($result) { foreach ($result as $child) { $this->updateChild($child['uid'], $child['tx_gridelements_container'], $child['tx_gridelements_columns']); } @@ -126,7 +128,7 @@ protected function updateChildren(int $uid): void protected function updateChild(int $uid, int $parent, int $colPos): void { - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TT_CONTENT_TABLE); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TT_CONTENT_TABLE); $queryBuilder->update(self::TT_CONTENT_TABLE) ->set('tx_container_parent', $parent) ->set('colPos', (100 + $colPos)) @@ -138,4 +140,4 @@ protected function updateChild(int $uid, int $parent, int $colPos): void ) ->executeStatement(); } -} \ No newline at end of file +} diff --git a/Classes/Upgrades/TruncateLogTableUpgradeWizard.php b/Classes/Upgrades/TruncateLogTableUpgradeWizard.php index 7c3df2f..400291c 100644 --- a/Classes/Upgrades/TruncateLogTableUpgradeWizard.php +++ b/Classes/Upgrades/TruncateLogTableUpgradeWizard.php @@ -1,18 +1,20 @@ getQueryBuilderForTable(self::LOG_TABLE); $queryBuilder->delete(self::LOG_TABLE); - if(ConfigurationUtility::getNumberOfDays()) { - $numberOfDays = ConfigurationUtility::getNumberOfDays(); + $emConfiguration = $this->extensionConfiguration->get('tm_migration'); + + if ($emConfiguration['numberOfDays']) { + $numberOfDays = $emConfiguration['numberOfDays']; $deleteTimestamp = strtotime('-' . $numberOfDays . 'days'); - - $queryBuilder->where( + + $queryBuilder->where( $queryBuilder->expr()->lt( self::DATE_FIELD, $queryBuilder->createNamedParameter($deleteTimestamp, Connection::PARAM_INT) @@ -86,7 +97,7 @@ private function deleteLogTable(): bool ); } - try { + try { $queryBuilder->executeStatement(); } catch (DBALException $e) { throw new \RuntimeException(self::class . ' failed for table ' . self::LOG_TABLE . ' with error: ' . $e->getMessage(), 1308255491); @@ -94,4 +105,4 @@ private function deleteLogTable(): bool return true; } -} \ No newline at end of file +} diff --git a/Classes/Utility/ConfigurationUtility.php b/Classes/Utility/ConfigurationUtility.php index 6a8a2e1..4dbd379 100644 --- a/Classes/Utility/ConfigurationUtility.php +++ b/Classes/Utility/ConfigurationUtility.php @@ -1,5 +1,6 @@ get('tm_migration'); } - + public static function isDisableTruncateLogUpgradeWizard(): bool { $configuration = self::getExtensionConfiguration(); return $configuration['disableTruncateLogUpgradeWizard'] === '1'; } - - public static function getNumberOfDays(): string - { - $configuration = self::getExtensionConfiguration(); - - return $configuration['numberOfDays'] ?? ''; - } public static function getUpgradeWizardToExclude(): array { $configuration = self::getExtensionConfiguration(); - if(!empty($configuration['upgradeWizards']['exlcuded'])) { - return explode(',', $configuration['upgradeWizards']['exlcuded']); + if (!empty($configuration['upgradeWizards']['exlcuded'])) { + return explode(',', $configuration['upgradeWizards']['exlcuded']); } return []; @@ -55,4 +48,4 @@ public static function getUpgradeWizardFromVersion(): string return $configuration['upgradeWizards']['fromVersion'] ?? ''; } -} \ No newline at end of file +} diff --git a/Classes/Utility/UpgardeWizardsMappingUtility.php b/Classes/Utility/UpgardeWizardsMappingUtility.php index 883decb..f99d59f 100644 --- a/Classes/Utility/UpgardeWizardsMappingUtility.php +++ b/Classes/Utility/UpgardeWizardsMappingUtility.php @@ -1,10 +1,8 @@ withPaths([__DIR__ . '/packages/']) ->withSets([ - Typo3LevelSetList::UP_TO_TYPO3_12 + Typo3LevelSetList::UP_TO_TYPO3_12, ]) ->withSkip([ // @see https://github.com/sabbelasichon/typo3-rector/issues/2536 diff --git a/Resources/Private/Config/Fractor/fractor_v13.php b/Resources/Private/Config/Fractor/fractor_v13.php index 5028007..3031dc1 100644 --- a/Resources/Private/Config/Fractor/fractor_v13.php +++ b/Resources/Private/Config/Fractor/fractor_v13.php @@ -1,17 +1,17 @@ withPaths([__DIR__ . '/packages/']) ->withSets([ - Typo3LevelSetList::UP_TO_TYPO3_13 + Typo3LevelSetList::UP_TO_TYPO3_13, ]) ->withSkip([ // @see https://github.com/sabbelasichon/typo3-rector/issues/2536 diff --git a/Resources/Private/Config/Rector/rector_v12.php b/Resources/Private/Config/Rector/rector_v12.php index 5b3c9a4..ee23588 100644 --- a/Resources/Private/Config/Rector/rector_v12.php +++ b/Resources/Private/Config/Rector/rector_v12.php @@ -23,9 +23,9 @@ Typo3SetList::GENERAL, Typo3LevelSetList::UP_TO_TYPO3_12, ]) - # To have a better analysis from PHPStan, we teach it here some more things + // To have a better analysis from PHPStan, we teach it here some more things ->withPHPStanConfigs([ - Typo3Option::PHPSTAN_FOR_RECTOR_PATH + Typo3Option::PHPSTAN_FOR_RECTOR_PATH, ]) ->withRules([ AddVoidReturnTypeWhereNoReturnRector::class, @@ -34,9 +34,9 @@ ->withConfiguredRule(ExtEmConfRector::class, [ ExtEmConfRector::PHP_VERSION_CONSTRAINT => '8.1.0-8.3.99', ExtEmConfRector::TYPO3_VERSION_CONSTRAINT => '12.4.0-12.4.99', - ExtEmConfRector::ADDITIONAL_VALUES_TO_BE_REMOVED => [] + ExtEmConfRector::ADDITIONAL_VALUES_TO_BE_REMOVED => [], ]) - # If you use withImportNames(), you should consider excluding some TYPO3 files. + // If you use withImportNames(), you should consider excluding some TYPO3 files. ->withSkip([ /*custom Mehdi*/ __DIR__ . '/var/cache', @@ -46,7 +46,7 @@ // @see https://github.com/sabbelasichon/typo3-rector/issues/2536 __DIR__ . '/**/Configuration/ExtensionBuilder/*', NameImportingPostRector::class => [ - 'ClassAliasMap.php', + 'ClassAliasMap.php', ], // Exlclude non autoloaded classes from DI injection // @see https://github.com/sabbelasichon/typo3-rector/issues/4604 diff --git a/Resources/Private/Config/Rector/rector_v13.php b/Resources/Private/Config/Rector/rector_v13.php index 4d394f9..f23d69d 100644 --- a/Resources/Private/Config/Rector/rector_v13.php +++ b/Resources/Private/Config/Rector/rector_v13.php @@ -52,4 +52,4 @@ __DIR__ . '/packages/**/Userfunc/*', ], ]) -; \ No newline at end of file +; diff --git a/Tests/Functional/Command/ExportCtypeListTypeCommandTest.php b/Tests/Functional/Command/ExportCtypeListTypeCommandTest.php new file mode 100644 index 0000000..ac8134b --- /dev/null +++ b/Tests/Functional/Command/ExportCtypeListTypeCommandTest.php @@ -0,0 +1,148 @@ +connectionPool = $this->createMock(ConnectionPool::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->subject = new ExportCtypeListTypeCommand($this->connectionPool, $this->logger, self::COMMAND_NAME); + $application = new Application(); + $application->add($this->subject); + + $command = $application->find(self::COMMAND_NAME); + $this->commandTester = new CommandTester($command); + } + + #[Test] + public function isConsoleCommand(): void + { + self::assertInstanceOf(Command::class, $this->subject); + } + + #[Test] + public function hasDescription(): void + { + $expected = 'Export CTypes and List Types to JSON or CSV.'; + self::assertSame($expected, $this->subject->getDescription()); + } + + #[Test] + public function executeGeneratesCsvFileSuccessfully(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $result = $this->createMock(Result::class); + $restrictionContainer = $this->createMock(DefaultRestrictionContainer::class); + $expressionBuilder = $this->createMock(ExpressionBuilder::class); + + $this->connectionPool + ->expects(self::once()) + ->method('getQueryBuilderForTable') + ->willReturn($queryBuilder); + + $queryBuilder->method('getRestrictions')->willReturn($restrictionContainer); + $restrictionContainer->method('removeAll')->willReturnSelf(); + + $queryBuilder->method('expr')->willReturn($expressionBuilder); + $expressionBuilder->method('notIn')->willReturn('not_in_expr'); + + $queryBuilder->method('selectLiteral')->willReturnSelf(); + $queryBuilder->method('addSelect')->willReturnSelf(); + $queryBuilder->method('from')->willReturnSelf(); + $queryBuilder->method('where')->willReturnSelf(); + $queryBuilder->method('groupBy')->willReturnSelf(); + $queryBuilder->method('addGroupBy')->willReturnSelf(); + $queryBuilder->method('createNamedParameter')->willReturn('param'); + $queryBuilder->method('executeQuery')->willReturn($result); + + $result->method('fetchAllAssociative')->willReturn([ + ['CType' => 'my_ctype', 'list_type' => 'my_list', 'pids' => '1,2,3'], + ]); + + $fileName = sys_get_temp_dir() . '/export_test.csv'; + + $exitCode = $this->commandTester->execute([ + '--fileName' => $fileName, + ]); + + self::assertFileExists($fileName); + self::assertSame(Command::SUCCESS, $exitCode); + + $content = file_get_contents($fileName); + self::assertStringContainsString('"CType","list_type","pids"', $content); + self::assertStringContainsString('my_ctype,my_list,1,2,3', str_replace('"', '', $content)); + + unlink($fileName); + } + + #[Test] + public function executeWithJsonOptionDisplaysListTypeMapping(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $result = $this->createMock(Result::class); + $restrictionContainer = $this->createMock(DefaultRestrictionContainer::class); + $expressionBuilder = $this->createMock(ExpressionBuilder::class); + + $this->connectionPool + ->expects(self::once()) + ->method('getQueryBuilderForTable') + ->willReturn($queryBuilder); + + $queryBuilder->method('getRestrictions')->willReturn($restrictionContainer); + $restrictionContainer->method('removeAll')->willReturnSelf(); + + $queryBuilder->method('expr')->willReturn($expressionBuilder); + $expressionBuilder->method('notIn')->willReturn('not_in_expr'); + + $queryBuilder->method('selectLiteral')->willReturnSelf(); + $queryBuilder->method('addSelect')->willReturnSelf(); + $queryBuilder->method('from')->willReturnSelf(); + $queryBuilder->method('where')->willReturnSelf(); + $queryBuilder->method('groupBy')->willReturnSelf(); + $queryBuilder->method('addGroupBy')->willReturnSelf(); + $queryBuilder->method('createNamedParameter')->willReturn('param'); + $queryBuilder->method('executeQuery')->willReturn($result); + + $result->method('fetchAllAssociative')->willReturn([ + ['CType' => 'list', 'list_type' => 'plugin_test', 'pids' => '99'], + ]); + + $exitCode = $this->commandTester->execute([ + '--fileType' => 'json', + ]); + + $output = $this->commandTester->getDisplay(); + self::assertStringContainsString('plugin_test:plugin_test', $output); + self::assertSame(Command::SUCCESS, $exitCode); + } +} diff --git a/Tests/Functional/Command/FixDatabaseErrorsCommandTest.php b/Tests/Functional/Command/FixDatabaseErrorsCommandTest.php new file mode 100644 index 0000000..813cda2 --- /dev/null +++ b/Tests/Functional/Command/FixDatabaseErrorsCommandTest.php @@ -0,0 +1,83 @@ +connectionPool = $this->createMock(ConnectionPool::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->subject = new FixDatabaseErrorsCommand($this->connectionPool, $this->logger, self::COMMAND_NAME); + $application = new Application(); + $application->add($this->subject); + + $command = $application->find(self::COMMAND_NAME); + $this->commandTester = new CommandTester($command); + } + + #[Test] + public function isConsoleCommand(): void + { + self::assertInstanceOf(Command::class, $this->subject); + } + + #[Test] + public function hasDescription(): void + { + $expected = 'Fix database updateschema errors'; + self::assertSame($expected, $this->subject->getDescription()); + } + + #[Test] + public function hasHelpText(): void + { + $expected = 'This command does nothing. It always succeeds.'; + self::assertSame($expected, $this->subject->getHelp()); + } + + #[Test] + public function executeWithNoAffectedTablesPrintsInfoAndSucceeds(): void + { + $exitCode = $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringContainsString('Nothing to process', $output); + self::assertStringContainsString('finished!', $output); + } + + #[Test] + public function executeWithAffectedTablesProcessesThem(): void + { + $exitCode = $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringContainsString('finished!', $output); + } +} diff --git a/Tests/Functional/Command/RunSqlScriptCommandTest.php b/Tests/Functional/Command/RunSqlScriptCommandTest.php new file mode 100644 index 0000000..355b90b --- /dev/null +++ b/Tests/Functional/Command/RunSqlScriptCommandTest.php @@ -0,0 +1,142 @@ +subject = new RunSqlScriptCommand(self::COMMAND_NAME); + $application = new Application(); + $application->add($this->subject); + + $command = $application->find(self::COMMAND_NAME); + $this->commandTester = new CommandTester($command); + } + + #[Test] + public function isConsoleCommand(): void + { + self::assertInstanceOf(Command::class, $this->subject); + } + + #[Test] + public function hasDescription(): void + { + $expected = 'Run custom SQL scripts.'; + self::assertSame($expected, $this->subject->getDescription()); + } + + #[Test] + public function hasHelpText(): void + { + $expected = <<subject->getHelp()); + } + + #[Test] + public function executesSqlFileSuccessfully(): void + { + $SQLFile = __DIR__ . '/../Fixtures/Database/queries.sql'; + + $mockMigrationService = $this->createMock(SQLMigrationService::class); + $mockMigrationService->expects(self::once()) + ->method('migrate') + ->willReturn(4); + + GeneralUtility::addInstance(SQLMigrationService::class, $mockMigrationService); + + $this->commandTester->execute([ + '--file' => $SQLFile, + ]); + + $output = $this->commandTester->getDisplay(); + self::assertStringContainsString('executed with no errors', $output); + self::assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + } + + #[Test] + public function failsIfFileIsEmpty(): void + { + $emptySQLFile = __DIR__ . '/../Fixtures/Database/empty.sql'; + + $mockMigrationService = $this->createMock(SQLMigrationService::class); + $mockMigrationService->expects(self::never())->method('migrate'); + + GeneralUtility::addInstance(SQLMigrationService::class, $mockMigrationService); + + $this->commandTester->execute([ + '--file' => $emptySQLFile, + ]); + + $output = $this->commandTester->getDisplay(); + self::assertStringContainsString('is empty', $output); + self::assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); + } + + #[Test] + public function failsIfNoFileFound(): void + { + $nonExistentSQLFile = __DIR__ . '/../Fixtures/Database/nonexistant.sql'; + + $mockMigrationService = $this->createMock(SQLMigrationService::class); + $mockMigrationService->expects(self::never())->method('migrate'); + + GeneralUtility::addInstance(SQLMigrationService::class, $mockMigrationService); + + $this->commandTester->execute([ + '--file' => $nonExistentSQLFile, + ]); + + $output = $this->commandTester->getDisplay(); + self::assertStringContainsString('no sql file found', $output); + self::assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); + } + + #[Test] + public function executesAllSqlFilesFromDirectory(): void + { + $SQLDir = __DIR__ . '/../Fixtures/Database/'; + + $mockMigrationService = $this->createMock(SQLMigrationService::class); + $mockMigrationService->expects(self::exactly(1)) + ->method('migrate') + ->willReturn(4); + + GeneralUtility::addInstance(SQLMigrationService::class, $mockMigrationService); + + $this->commandTester->execute([ + '--directory' => $SQLDir, + ]); + + $output = $this->commandTester->getDisplay(); + self::assertStringContainsString('executed with no errors', $output); + self::assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + } +} diff --git a/Tests/Functional/Command/SeperateSyshistoryFromSyslogCommandTest.php b/Tests/Functional/Command/SeperateSyshistoryFromSyslogCommandTest.php new file mode 100644 index 0000000..8a3c8fe --- /dev/null +++ b/Tests/Functional/Command/SeperateSyshistoryFromSyslogCommandTest.php @@ -0,0 +1,114 @@ +mockSQLService = $this->createMock(SQLMigrationService::class); + GeneralUtility::addInstance(SQLMigrationService::class, $this->mockSQLService); + + $this->subject = new SeperateSyshistoryFromSyslogCommand(self::COMMAND_NAME); + $application = new Application(); + $application->add($this->subject); + + $command = $application->find('tmupgrade:sepearate-syshistory-from-syslog'); + $this->commandTester = new CommandTester($command); + } + + #[Test] + public function isConsoleCommand(): void + { + self::assertInstanceOf(Command::class, $this->subject); + } + + #[Test] + public function hasDescription(): void + { + $expected = 'Seperate sys_history entries from sys_log.'; + self::assertSame($expected, $this->subject->getDescription()); + } + + #[Test] + public function hasHelpText(): void + { + $expected = 'This command execute an SQL script that seperates sys_history from sys_log table. -d days -l limit.'; + self::assertSame($expected, $this->subject->getHelp()); + } + + #[Test] + public function testExecuteSuccess() + { + $this->mockSQLService + ->expects(self::once()) + ->method('migrate') + ->with(self::callback(function ($statements) { + $sql = $statements[0]; + $this->assertStringContainsString('DELETE FROM sys_log', $sql); + $this->assertStringContainsString('recuid=0', $sql); + return true; + })) + ->willReturn(1); + + $exitCode = $this->commandTester->execute(['--limit' => 100, '--days' => 7]); + self::assertSame(0, $exitCode); + } + + #[Test] + public function testExecuteFailure() + { + $this->mockSQLService + ->expects(self::once()) + ->method('migrate') + ->willReturn(0); + + $exitCode = $this->commandTester->execute([]); + self::assertSame(1, $exitCode); + } + + #[Test] + public function testSqlContainsLimitAndTimestamp() + { + $days = 3; + $limit = 50; + $expectedTimestamp = strtotime('-' . $days . ' days'); + + $this->mockSQLService + ->expects(self::once()) + ->method('migrate') + ->with(self::callback(function ($statements) use ($limit, $expectedTimestamp) { + $sql = $statements[0]; + $this->assertStringContainsString('LIMIT ' . $limit, $sql); + $this->assertStringContainsString((string)$expectedTimestamp, $sql); + return true; + })) + ->willReturn(1); + + $this->commandTester->execute(['--limit' => $limit, '--days' => $days]); + } +} diff --git a/Tests/Functional/Command/UpgradeWizardRunCommandTest.php b/Tests/Functional/Command/UpgradeWizardRunCommandTest.php new file mode 100644 index 0000000..034900e --- /dev/null +++ b/Tests/Functional/Command/UpgradeWizardRunCommandTest.php @@ -0,0 +1,100 @@ + 'pi_plugin1:new_content_element1,pi_plugin2:new_content_element2', + ]; + + $this->lateBootService = $this->createMock(LateBootService::class); + $this->databaseUpgradeWizardsService = $this->createMock(DatabaseUpgradeWizardsService::class); + $this->silentConfigurationUpgradeService = $this->createMock(SilentConfigurationUpgradeService::class); + + $mockUpgradeWizardsService = $this->createMock(UpgradeWizardsService::class); + + $mockContainer = $this->createMock(ContainerInterface::class); + $mockContainer->method('get') + ->with(UpgradeWizardsService::class) + ->willReturn($mockUpgradeWizardsService); + + $this->lateBootService->method('loadExtLocalconfDatabaseAndExtTables') + ->willReturn($mockContainer); + + $this->databaseUpgradeWizardsService->method('isDatabaseCharsetUtf8') + ->willReturn(true); + + $this->subject = new UpgradeWizardRunCommand( + $this->lateBootService, + $this->databaseUpgradeWizardsService, + $this->silentConfigurationUpgradeService, + self::COMMAND_NAME + ); + + $application = new Application(); + $application->add($this->subject); + + $command = $application->find(self::COMMAND_NAME); + $this->commandTester = new CommandTester($command); + } + + #[Test] + public function isConsoleCommand(): void + { + self::assertInstanceOf(Command::class, $this->subject); + } + + #[Test] + public function hasDescription(): void + { + $expected = 'Run upgrade wizard. Without arguments all available wizards will be run.'; + self::assertSame($expected, $this->subject->getDescription()); + } + + #[Test] + public function hasHelpText(): void + { + $expected = 'This command allows running upgrade wizards on CLI. To run a single wizard add the ' + . 'identifier of the wizard as argument. The identifier of the wizard is the name it is ' + . 'registered with in ext_localconf.'; + self::assertSame($expected, $this->subject->getHelp()); + } + + #[Test] + public function testExecuteSuccess(): void + { + $exitCode = $this->commandTester->execute([]); + self::assertSame(0, $exitCode); + } +} diff --git a/Tests/Functional/Fixtures/Database/empty.sql b/Tests/Functional/Fixtures/Database/empty.sql new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Functional/Fixtures/Database/queries.sql b/Tests/Functional/Fixtures/Database/queries.sql new file mode 100644 index 0000000..7ab14fc --- /dev/null +++ b/Tests/Functional/Fixtures/Database/queries.sql @@ -0,0 +1,25 @@ +-- Create a sample table +CREATE TABLE IF NOT EXISTS tx_tmexample_table ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Insert some sample data +INSERT INTO tx_tmexample_table (title, description) VALUES +('First Item', 'This is the first item.'), +('Second Item', 'This is the second item.'); + +-- Another example table +CREATE TABLE IF NOT EXISTS tx_tmexample_users ( + uid INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Insert sample users +INSERT INTO tx_tmexample_users (username, email) VALUES +('user1', 'user1@example.com'), +('user2', 'user2@example.com'); diff --git a/Tests/Functional/Fixtures/sys_log.csv b/Tests/Functional/Fixtures/sys_log.csv new file mode 100644 index 0000000..bca93da --- /dev/null +++ b/Tests/Functional/Fixtures/sys_log.csv @@ -0,0 +1,7 @@ +"sys_log" +,"uid","tstamp","details" +,1,1723500000,"Old log entry — older than 90 days (should be deleted)" +,2,1726000000,"Log entry — around 60 days old (should be deleted if threshold < 60)" +,3,1728500000,"Recent log entry — 30 days old (should be kept)" +,4,1730400000,"New entry — within the last week (should be kept)" +,5,1710000000,"Very old log entry — over 200 days old (should be deleted)" \ No newline at end of file diff --git a/Tests/Functional/Fixtures/sys_redirect.csv b/Tests/Functional/Fixtures/sys_redirect.csv new file mode 100644 index 0000000..6883a3b --- /dev/null +++ b/Tests/Functional/Fixtures/sys_redirect.csv @@ -0,0 +1,5 @@ +"sys_redirect" +,"uid","pid","source_path","target_statuscode","target","is_regexp" +,1,0,"no-leading-slash",307,"/target1",0 +,2,0,"^products/(.*)$",301,"target2",1 +,3,0,"https://www.google.com",301,"target3",0 \ No newline at end of file diff --git a/Tests/Functional/Service/SQLMigrationServiceTest.php b/Tests/Functional/Service/SQLMigrationServiceTest.php new file mode 100644 index 0000000..69b0717 --- /dev/null +++ b/Tests/Functional/Service/SQLMigrationServiceTest.php @@ -0,0 +1,46 @@ +queries = array_filter(array_map('trim', explode(';', $sqlContent))); + } + + #[Test] + public function checkSqlQueriesMigration(): void + { + $service = GeneralUtility::makeInstance(SQLMigrationService::class); + $executedCount = $service->migrate($this->queries); + + self::assertSame(count($this->queries), $executedCount, 'All SQL queries should be executed.'); + + $connection = GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionByName('Default'); + + $result = $connection->fetchOne('SELECT COUNT(*) FROM tx_tmexample_table'); + self::assertGreaterThan(0, $result, 'Data should be inserted into tx_tmexample_table'); + } +} diff --git a/Tests/Functional/Upgrades/FixRedirectsUpgraeWizardTest.php b/Tests/Functional/Upgrades/FixRedirectsUpgraeWizardTest.php new file mode 100644 index 0000000..e587271 --- /dev/null +++ b/Tests/Functional/Upgrades/FixRedirectsUpgraeWizardTest.php @@ -0,0 +1,72 @@ +subject = new FixRedirectsUpgraeWizard(); + + $this->importCSVDataSet(__DIR__ . '/../Fixtures/sys_redirect.csv'); + } + + #[Test] + public function isUpgradeWizard(): void + { + self::assertInstanceOf(UpgradeWizardInterface::class, $this->subject); + } + + #[Test] + public function hasTitle(): void + { + $expected = 'Repair Invalid Redirects'; + self::assertSame($expected, $this->subject->getTitle()); + } + + #[Test] + public function hasDescription(): void + { + $expected = 'This upgrade wizard identifies and corrects invalid or outdated redirect entries in the database.'; + self::assertSame($expected, $this->subject->getDescription()); + } + + #[Test] + public function testWizardRepairsInvalidRedirects(): void + { + $wizard = new FixRedirectsUpgraeWizard(); + + self::assertTrue($wizard->updateNecessary()); + $wizard->executeUpdate(); + + $connection = $this->getConnectionPool()->getConnectionForTable('sys_redirect'); + $redirects = $connection->select( + ['uid', 'source_path', 'target_statuscode', 'target'], + 'sys_redirect' + )->fetchAllAssociative(); + + $redirectsByUid = []; + foreach ($redirects as $row) { + $redirectsByUid[$row['uid']] = $row; + } + + // 1. no-leading-slash + self::assertSame('/no-leading-slash', $redirectsByUid[1]['source_path']); + // 2. regex pattern + self::assertSame('^products/(.*)$', $redirectsByUid[2]['source_path']); + // 3. external URL + self::assertSame('https://www.google.com', $redirectsByUid[3]['source_path']); + } +} diff --git a/Tests/Functional/Upgrades/GridElementsToContainerUpgradeWizardTest.php b/Tests/Functional/Upgrades/GridElementsToContainerUpgradeWizardTest.php new file mode 100644 index 0000000..a32cd6c --- /dev/null +++ b/Tests/Functional/Upgrades/GridElementsToContainerUpgradeWizardTest.php @@ -0,0 +1,105 @@ +queryBuilderMock = $this->createMock(QueryBuilder::class); + + $restrictionMock = $this->createMock(QueryRestrictionContainerInterface::class); + $restrictionMock->method('removeAll')->willReturnSelf(); + $restrictionMock->method('add')->willReturnSelf(); + + $this->queryBuilderMock + ->method('getRestrictions') + ->willReturn($restrictionMock); + + $this->connectionPoolMock = $this->createMock(ConnectionPool::class); + $this->connectionPoolMock + ->method('getQueryBuilderForTable') + ->willReturn($this->queryBuilderMock); + + GeneralUtility::addInstance(ConnectionPool::class, $this->connectionPoolMock); + + $this->subject = new GridElementsToContainerUpgradeWizard($this->connectionPoolMock); + } + + #[Test] + public function isUpgradeWizard(): void + { + self::assertInstanceOf(UpgradeWizardInterface::class, $this->subject); + } + + #[Test] + public function hasTitle(): void + { + self::assertSame('Migrate Gridelements to Container', $this->subject->getTitle()); + } + + #[Test] + public function hasDescription(): void + { + self::assertSame( + 'This update wizard switches from EXT:gridelements to EXT:container.', + $this->subject->getDescription() + ); + } + + #[Test] + public function testExecuteUpdateProcessesFoundGridelements(): void + { + $gridElement = [ + 'uid' => 1, + 'tx_gridelements_backend_layout' => 'container_2col', + 'pi_flexform' => '', + ]; + + $result = $this->createMock(Result::class); + $result->method('fetchAllAssociative')->willReturn([$gridElement]); + + $this->queryBuilderMock + ->method('select')->willReturn($this->queryBuilderMock); + $this->queryBuilderMock + ->method('from')->willReturn($this->queryBuilderMock); + $this->queryBuilderMock + ->method('where')->willReturn($this->queryBuilderMock); + $this->queryBuilderMock + ->method('executeQuery')->willReturn($result); + + $wizard = $this->getMockBuilder(GridElementsToContainerUpgradeWizard::class) + ->setConstructorArgs([$this->connectionPoolMock]) + ->onlyMethods(['updateContainer', 'updateChildren']) + ->getMock(); + + $wizard->expects(self::once()) + ->method('updateContainer') + ->with(1, 'container_2col', ''); + + $wizard->expects(self::once()) + ->method('updateChildren') + ->with(1); + + self::assertTrue($wizard->executeUpdate()); + } +} diff --git a/Tests/Functional/Upgrades/TruncateLogTableUpgradeWizardTest.php b/Tests/Functional/Upgrades/TruncateLogTableUpgradeWizardTest.php new file mode 100644 index 0000000..8bb3d08 --- /dev/null +++ b/Tests/Functional/Upgrades/TruncateLogTableUpgradeWizardTest.php @@ -0,0 +1,99 @@ +extensionConfigurationMock = $this->createMock(ExtensionConfiguration::class); + GeneralUtility::addInstance(ExtensionConfiguration::class, $this->extensionConfigurationMock); + + $this->subject = new TruncateLogTableUpgradeWizard($this->extensionConfigurationMock); + + $this->importCSVDataSet(__DIR__ . '/../Fixtures/sys_log.csv'); + } + + #[Test] + public function isUpgradeWizard(): void + { + self::assertInstanceOf(UpgradeWizardInterface::class, $this->subject); + } + + #[Test] + public function hasTitle(): void + { + $expected = 'Truncate/Delete entries from Log Table'; + self::assertSame($expected, $this->subject->getTitle()); + } + + #[Test] + public function hasDescription(): void + { + $expected = 'This update wizard truncates/deletes Log entries based on given lifetime.'; + self::assertSame($expected, $this->subject->getDescription()); + } + + #[Test] + public function updateIsNotNecessaryWhenDisabledInConfiguration(): void + { + $this->extensionConfigurationMock + ->method('get') + ->with('tm_migration') + ->willReturn(['disableTruncateLogUpgradeWizard' => '1']); + + $wizard = new TruncateLogTableUpgradeWizard($this->extensionConfigurationMock); + self::assertFalse($wizard->updateNecessary()); + } + + #[Test] + public function updateIsNecessaryWhenEnabledInConfiguration(): void + { + $this->extensionConfigurationMock + ->method('get') + ->with('tm_migration') + ->willReturn(['disableTruncateLogUpgradeWizard' => '0']); + + $wizard = new TruncateLogTableUpgradeWizard($this->extensionConfigurationMock); + self::assertTrue($wizard->updateNecessary()); + } + + #[Test] + public function executeUpdateDeletesEntriesOlderThanConfiguredDays(): void + { + $this->extensionConfigurationMock + ->method('get') + ->with('tm_migration') + ->willReturn([ + 'disableTruncateLogUpgradeWizard' => false, + 'numberOfDays' => '40', + ]); + + $wizard = new TruncateLogTableUpgradeWizard($this->extensionConfigurationMock); + $result = $wizard->executeUpdate(); + + self::assertTrue($result); + + $connection = $this->getConnectionPool()->getConnectionForTable('sys_log'); + $count = $connection->count('*', 'sys_log', []); + + self::assertSame(0, $count); + } +} diff --git a/Tests/Unit/ConfigurationUtilityTest.php b/Tests/Unit/ConfigurationUtilityTest.php new file mode 100644 index 0000000..c3aa97a --- /dev/null +++ b/Tests/Unit/ConfigurationUtilityTest.php @@ -0,0 +1,56 @@ +extensionConfigurationMock = $this->createMock(ExtensionConfiguration::class); + GeneralUtility::addInstance(ExtensionConfiguration::class, $this->extensionConfigurationMock); + } + + #[Test] + public function testIsDisableTruncateLogUpgradeWizardReturnsTrue(): void + { + $this->extensionConfigurationMock + ->method('get') + ->with('tm_migration') + ->willReturn(['disableTruncateLogUpgradeWizard' => '1']); + + self::assertTrue(ConfigurationUtility::isDisableTruncateLogUpgradeWizard()); + } + + #[Test] + public function testGetUpgradeWizardToExcludeHandlesEmptyConfiguration(): void + { + $this->extensionConfigurationMock + ->method('get') + ->willReturn([]); + + self::assertSame([], ConfigurationUtility::getUpgradeWizardToExclude()); + } + + #[Test] + public function testGetUpgradeWizardFromVersionReturnsExpectedValue(): void + { + $this->extensionConfigurationMock + ->method('get') + ->willReturn(['upgradeWizards' => ['fromVersion' => '12.4.0']]); + + self::assertSame('12.4.0', ConfigurationUtility::getUpgradeWizardFromVersion()); + } +} diff --git a/Tests/Unit/Upgrades/CTypeToListTypeUpgradeWizardTest.php b/Tests/Unit/Upgrades/CTypeToListTypeUpgradeWizardTest.php new file mode 100644 index 0000000..86e4687 --- /dev/null +++ b/Tests/Unit/Upgrades/CTypeToListTypeUpgradeWizardTest.php @@ -0,0 +1,96 @@ +connectionPool = $this->createMock(ConnectionPool::class); + $this->extensionConfiguration = $this->createMock(ExtensionConfiguration::class); + + $this->subject = new CTypeToListTypeUpgradeWizard( + $this->connectionPool, + $this->extensionConfiguration + ); + } + + #[Test] + public function isUpgradeWizard(): void + { + self::assertInstanceOf(UpgradeWizardInterface::class, $this->subject); + } + + #[Test] + public function hasTitle(): void + { + $expected = 'Migrate plugins to content elements.'; + self::assertSame($expected, $this->subject->getTitle()); + } + + #[Test] + public function hasDescription(): void + { + $expected = 'This command migrates plugins [list_type] to content elements [ctype].'; + self::assertSame($expected, $this->subject->getDescription()); + } + + #[Test] + public function testReturnsDefaultMappingWhenNoConfigurationExists(): void + { + $this->extensionConfiguration + ->method('get') + ->with('tm_migration') + ->willReturn(null); + + $method = new \ReflectionMethod($this->subject, 'getListTypeToCTypeMapping'); + $method->setAccessible(true); + + $result = $method->invoke($this->subject); + + $expected = [ + 'pi_plugin1' => 'new_content_element1', + ]; + + self::assertSame($expected, $result); + } + + #[Test] + public function testReturnsCustomMappingWhenConfigurationExists(): void + { + $this->extensionConfiguration + ->method('get') + ->with('tm_migration') + ->willReturn([ + 'cTypeToListTypeMappingArray' => 'pi_plugin1:new_content_element1,pi_plugin2:new_content_element2', + ]); + + $method = new \ReflectionMethod($this->subject, 'getListTypeToCTypeMapping'); + $method->setAccessible(true); + + $result = $method->invoke($this->subject); + + $expected = [ + 'pi_plugin1' => 'new_content_element1', + 'pi_plugin2' => 'new_content_element2', + ]; + + self::assertSame($expected, $result); + } +} diff --git a/composer.json b/composer.json index cff04fe..558660c 100644 --- a/composer.json +++ b/composer.json @@ -20,11 +20,20 @@ } ], "require": { - "php": "^8.1 || ^8.2", + "php": ">= 8.1 < 8.5", "typo3/cms-core": "^12.4.0 || ^13.4.0", "a9f/typo3-fractor": "^v0.5.1", "ssch/typo3-rector": "^2.14.4 || ^v3.5.0", - "wapplersystems/core-upgrader": "dev-release/v12 || dev-release/v13" + "wapplersystems/core-upgrader": "dev-release/v12 || dev-release/v13", + "typo3/cms-redirects": "^13.4" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^2.2.0", + "typo3/testing-framework": "^9.2.0", + "phpunit/phpunit": "^11.5.33", + "typo3/coding-standards": "^0.8.0", + "friendsofphp/php-cs-fixer": "^3.86.0", + "dg/bypass-finals": "^1.9" }, "autoload": { "psr-4": { @@ -39,9 +48,29 @@ "replace": { "typo3-ter/tm-migration": "self.version" }, + "config": { + "vendor-dir": ".Build/vendor", + "bin-dir": ".Build/bin", + "allow-plugins": { + "typo3/class-alias-loader": true, + "typo3/cms-composer-installers": true, + "a9f/fractor-extension-installer": true + } + }, + "scripts": { + "post-install-cmd": [ + "chmod +x .githooks/pre-commit", + "git init", + "git config --local core.hooksPath .githooks/" + ], + "php:cs": "php-cs-fixer fix --config=Build/php-cs-fixer/php-cs-fixer.php -v --dry-run --using-cache no --diff", + "php:unit": "phpunit -c Build/phpunit/UnitTests.xml", + "php:fix": "php-cs-fixer fix --config=Build/php-cs-fixer/php-cs-fixer.php" + }, "extra": { "typo3/cms": { - "extension-key": "tm_migration" + "extension-key": "tm_migration", + "web-dir": ".Build/Web" } - } + } } diff --git a/ext_emconf.php b/ext_emconf.php index f1e1075..f508b34 100644 --- a/ext_emconf.php +++ b/ext_emconf.php @@ -7,12 +7,12 @@ 'author' => 'Haythem Daoud', 'author_email' => 'haythem.daoud@toumoro.com', 'state' => 'stable', - 'version' => '13.4.9', + 'version' => '13.4.10', 'constraints' => [ 'depends' => [ - 'a9f/typo3-fractor' => '*', - 'ssch/typo3-rector' => '*', - 'wapplersystems/core-upgrader' => '*' + 'a9f/typo3-fractor' => '0.5.0-0.9.0', + 'ssch/typo3-rector' => '2.0.0-3.9.0', + 'wapplersystems/core-upgrader' => 'dev-release/v12 || dev-release/v13', ], 'conflicts' => [], 'suggests' => [], diff --git a/ext_localconf.php b/ext_localconf.php index 87cbf83..a2393b3 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -1,4 +1,5 @@ \Toumoro\TmMigration\Xclass\Updates\WorkspacesNotificationSettingsUpdate::class, ]; -}); \ No newline at end of file +}); diff --git a/ext_tables.php b/ext_tables.php index 885c73b..45a0d7e 100644 --- a/ext_tables.php +++ b/ext_tables.php @@ -1,2 +1,3 @@