diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 99d0dc28..e37c0788 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -10,4 +10,5 @@ categories: - "Documentation :books:" template: | ## What’s Changed + $CHANGES diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index c4af9443..5e6812cd 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -12,7 +12,7 @@ jobs: name: Build Release runs-on: ubuntu-latest container: - image: atk4/image:latest + image: ghcr.io/mvorisek/image-php:latest steps: - uses: actions/checkout@v2 with: diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 6cbb1df5..be2abb19 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -11,7 +11,7 @@ jobs: name: Smoke runs-on: ubuntu-latest container: - image: atk4/image:${{ matrix.php }} + image: ghcr.io/mvorisek/image-php:${{ matrix.php }} strategy: fail-fast: false matrix: @@ -22,15 +22,13 @@ jobs: type: 'CodingStyle' - php: 'latest' type: 'StaticAnalysis' - env: - LOG_COVERAGE: "" steps: - name: Checkout uses: actions/checkout@v2 - name: Configure PHP run: | - if [ -n "$LOG_COVERAGE" ]; then echo "xdebug.mode=coverage" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini; else rm /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini; fi + rm /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini php --version - name: Setup cache 1/2 @@ -39,8 +37,7 @@ jobs: echo "::set-output name=dir::$(composer config cache-files-dir)" - name: Setup cache 2/2 - if: ${{ !env.ACT }} - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-smoke-${{ matrix.php }}-${{ matrix.type }}-${{ hashFiles('composer.json') }} @@ -49,46 +46,64 @@ jobs: - name: Install PHP dependencies run: | - if [ "${{ matrix.type }}" != "Phpunit" ] && [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpunit/phpunit johnkary/phpunit-speedtrap --dev ; fi - if [ "${{ matrix.type }}" != "CodingStyle" ]; then composer remove --no-interaction --no-update friendsofphp/php-cs-fixer --dev ; fi - if [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpstan/phpstan --dev ; fi + if [ "${{ matrix.type }}" != "Phpunit" ] && [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpunit/phpunit johnkary/phpunit-speedtrap phpunit/phpcov --dev; fi + if [ "${{ matrix.type }}" != "CodingStyle" ]; then composer remove --no-interaction --no-update friendsofphp/php-cs-fixer --dev; fi + if [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpstan/phpstan 'behat/*' --dev; fi composer update --ansi --prefer-dist --no-interaction --no-progress --optimize-autoloader - - name: Init + - name: "Run tests: SQLite (only for Phpunit)" + if: startsWith(matrix.type, 'Phpunit') run: | - mkdir -p build/logs - - - name: "Run tests: Phpunit (only for Phpunit)" - if: matrix.type == 'Phpunit' - run: "vendor/bin/phpunit \"$(if [ -n \"$LOG_COVERAGE\" ]; then echo '--coverage-text'; else echo '--no-coverage'; fi)\" -v" + php demos/_demo-data/create-db.php + vendor/bin/phpunit --exclude-group none --no-coverage -v - name: Check Coding Style (only for CodingStyle) if: matrix.type == 'CodingStyle' - run: vendor/bin/php-cs-fixer fix --dry-run --using-cache=no --diff --diff-format=udiff --verbose --show-progress=dots + run: | + if [ "$(find demos/ -name '*.php' -print0 | xargs -0 grep -L "namespace Atk4\\\\Login\\\\Demos[;\\\\]" | tee /dev/fd/2)" ]; then echo 'All demos/ files must have namespace declared' && (exit 1); fi + vendor/bin/php-cs-fixer fix --dry-run --using-cache=no --diff --verbose - name: Run Static Analysis (only for StaticAnalysis) if: matrix.type == 'StaticAnalysis' run: | - echo "memory_limit = 1G" > /usr/local/etc/php/conf.d/custom-memory-limit.ini + echo "memory_limit = 2G" > /usr/local/etc/php/conf.d/custom-memory-limit.ini vendor/bin/phpstan analyse unit-test: name: Unit runs-on: ubuntu-latest container: - image: atk4/image:${{ matrix.php }} + image: ghcr.io/mvorisek/image-php:${{ matrix.php }} strategy: fail-fast: false matrix: - php: ['7.3', '7.4', 'latest'] - type: ['Phpunit'] - include: - - php: 'latest' - type: 'Phpunit Lowest' - - php: 'latest' - type: 'Phpunit Burn' + php: ['7.4', '8.0'] + type: ['Phpunit', 'Phpunit Lowest'] env: - LOG_COVERAGE: "${{ fromJSON('{true: \"1\", false: \"\"}')[matrix.php == 'latest' && matrix.type == 'Phpunit' && (github.event_name == 'pull_request' || (github.event_name == 'push' && (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master')))] }}" + LOG_COVERAGE: "${{ fromJSON('{true: \"1\", false: \"\"}')[matrix.php == '8.0' && matrix.type == 'Phpunit' && (github.event_name == 'pull_request' || (github.event_name == 'push' && (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master')))] }}" + services: + mysql: + image: mysql:8 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 -e MYSQL_ROOT_PASSWORD=atk4_pass_root -e MYSQL_USER=atk4_test_user -e MYSQL_PASSWORD=atk4_pass -e MYSQL_DATABASE=atk4_test --entrypoint sh mysql:8 -c "exec docker-entrypoint.sh mysqld --default-authentication-plugin=mysql_native_password" + mariadb: + image: mariadb + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 -e MYSQL_ROOT_PASSWORD=atk4_pass_root -e MYSQL_USER=atk4_test_user -e MYSQL_PASSWORD=atk4_pass -e MYSQL_DATABASE=atk4_test + postgres: + image: postgres:12-alpine + env: + POSTGRES_USER: atk4_test_user + POSTGRES_PASSWORD: atk4_pass + POSTGRES_DB: atk4_test + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + mssql: + image: mcr.microsoft.com/mssql/server + env: + ACCEPT_EULA: Y + SA_PASSWORD: atk4_pass + oracle: + image: ghcr.io/mvorisek/docker-oracle-xe-11g + env: + ORACLE_ALLOW_REMOTE: true steps: - name: Checkout uses: actions/checkout@v2 @@ -104,8 +119,7 @@ jobs: echo "::set-output name=dir::$(composer config cache-files-dir)" - name: Setup cache 2/2 - if: ${{ !env.ACT }} - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.type }}-${{ hashFiles('composer.json') }} @@ -114,41 +128,130 @@ jobs: - name: Install PHP dependencies run: | - if [ "${{ matrix.type }}" != "Phpunit" ] && [ "${{ matrix.type }}" != "Phpunit Lowest" ] && [ "${{ matrix.type }}" != "Phpunit Burn" ]; then composer remove --no-interaction --no-update phpunit/phpunit --no-update phpunit/phpunit johnkary/phpunit-speedtrap --dev ; fi - if [ "${{ matrix.type }}" != "CodingStyle" ]; then composer remove --no-interaction --no-update friendsofphp/php-cs-fixer --dev ; fi - if [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpstan/phpstan --dev ; fi + if [ "${{ matrix.type }}" != "Phpunit" ] && [ "${{ matrix.type }}" != "Phpunit Lowest" ] && [ "${{ matrix.type }}" != "Phpunit Burn" ]; then composer remove --no-interaction --no-update phpunit/phpunit johnkary/phpunit-speedtrap phpunit/phpcov --dev; fi + if [ "${{ matrix.type }}" != "CodingStyle" ]; then composer remove --no-interaction --no-update friendsofphp/php-cs-fixer --dev; fi + if [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpstan/phpstan 'behat/*' --dev; fi composer update --ansi --prefer-dist --no-interaction --no-progress --optimize-autoloader - if [ "${{ matrix.type }}" == "Phpunit Lowest" ]; then composer update --ansi --prefer-dist --prefer-lowest --prefer-stable --no-interaction --no-progress --optimize-autoloader ; fi - if [ "${{ matrix.type }}" == "Phpunit Burn" ]; then sed -i 's/ *public function runBare(): void/public function runBare(): void { gc_collect_cycles(); $mem0 = memory_get_usage(); for ($i = 0; $i < '"$(if [ \"$GITHUB_EVENT_NAME\" == \"schedule\" ]; then echo 5; else echo 5; fi)"'; ++$i) { $this->_runBare(); if ($i === 0) { gc_collect_cycles(); $mem1 = memory_get_usage(); } } gc_collect_cycles(); $mem2 = memory_get_usage(); if ($mem2 - 4000 * 1024 > $mem0 || $mem2 - 1536 * 1024 > $mem1) { $this->onNotSuccessfulTest(new AssertionFailedError("Memory leak detected! (" . round($mem0 \/ (1024 * 1024), 3) . " + " . round(($mem1 - $mem0) \/ (1024 * 1024), 3) . " + " . round(($mem2 - $mem1) \/ (1024 * 1024), 3) . " MB, " . $i . " iterations)")); } } private function _runBare(): void/' vendor/phpunit/phpunit/src/Framework/TestCase.php && cat vendor/phpunit/phpunit/src/Framework/TestCase.php | grep '_runBare(' ; fi + if [ "${{ matrix.type }}" == "Phpunit Lowest" ]; then composer update --ansi --prefer-dist --prefer-lowest --prefer-stable --no-interaction --no-progress --optimize-autoloader; fi + if [ "${{ matrix.type }}" == "Phpunit Burn" ]; then sed -i 's~ *public function runBare(): void~public function runBare(): void { gc_collect_cycles(); gc_collect_cycles(); $memDiffs = array_fill(0, '"$(if [ \"$GITHUB_EVENT_NAME\" == \"schedule\" ]; then echo 64; else echo 16; fi)"', 0); for ($i = -1; $i < count($memDiffs); ++$i) { $this->_runBare(); gc_collect_cycles(); gc_collect_cycles(); $mem = memory_get_usage(); if ($i !== -1) { $memDiffs[$i] = $mem - $memPrev; } $memPrev = $mem; rsort($memDiffs); if (array_sum($memDiffs) >= 4096 * 1024 || $memDiffs[2] > 0) { $this->onNotSuccessfulTest(new AssertionFailedError( "Memory leak detected! (" . implode(" + ", array_map(fn ($v) => number_format($v / 1024, 3, ".", " "), array_filter($memDiffs))) . " KB, " . ($i + 2) . " iterations)" )); } } } private function _runBare(): void~' vendor/phpunit/phpunit/src/Framework/TestCase.php && cat vendor/phpunit/phpunit/src/Framework/TestCase.php | grep '_runBare('; fi - name: Init run: | - mkdir -p build/logs + php -r '(new PDO("mysql:host=mysql", "root", "atk4_pass_root"))->exec("ALTER USER '"'"'atk4_test_user'"'"'@'"'"'%'"'"' WITH MAX_USER_CONNECTIONS 5");' + php -r '(new PDO("mysql:host=mariadb", "root", "atk4_pass_root"))->exec("ALTER USER '"'"'atk4_test_user'"'"'@'"'"'%'"'"' WITH MAX_USER_CONNECTIONS 5");' + php -r '(new PDO("pgsql:host=postgres;dbname=atk4_test", "atk4_test_user", "atk4_pass"))->exec("ALTER ROLE atk4_test_user CONNECTION LIMIT 1");' + if [ -n "$LOG_COVERAGE" ]; then mkdir coverage && cp tools/CoverageUtil.php demos; fi + sed -E "s/\(('sqlite:.+)\);/(\$_ENV['DB_DSN'] ?? \\1, \$_ENV['DB_USER'] ?? null, \$_ENV['DB_PASSWORD'] ?? null);/g" -i demos/db.default.php - - name: "Run tests: Phpunit (only for Phpunit)" - if: startsWith(matrix.type, 'Phpunit') - run: "vendor/bin/phpunit \"$(if [ -n \"$LOG_COVERAGE\" ]; then echo '--coverage-text'; else echo '--no-coverage'; fi)\" -v" + - name: "Run tests: SQLite" + run: | + php demos/_demo-data/create-db.php + php -d opcache.enable_cli=1 vendor/bin/phpunit --exclude-group none $(if [ -n "$LOG_COVERAGE" ]; then echo --coverage-text; else echo --no-coverage; fi) -v + if [ -n "$LOG_COVERAGE" ]; then mv coverage/phpunit.cov coverage/phpunit-sqlite.cov; fi + + - name: "Run tests: MySQL" + env: + DB_DSN: "mysql:host=mysql;dbname=atk4_test" + DB_USER: atk4_test_user + DB_PASSWORD: atk4_pass + run: | + php demos/_demo-data/create-db.php + php -d opcache.enable_cli=1 vendor/bin/phpunit --exclude-group none $(if [ -n "$LOG_COVERAGE" ]; then echo --coverage-text; else echo --no-coverage; fi) -v + if [ -n "$LOG_COVERAGE" ]; then mv coverage/phpunit.cov coverage/phpunit-mysql.cov; fi - - name: Upload coverage logs (only for "latest" Phpunit) + - name: "Run tests: MariaDB" + env: + DB_DSN: "mysql:host=mariadb;dbname=atk4_test" + DB_USER: atk4_test_user + DB_PASSWORD: atk4_pass + run: | + php demos/_demo-data/create-db.php + php -d opcache.enable_cli=1 vendor/bin/phpunit --exclude-group none $(if [ -n "$LOG_COVERAGE" ]; then echo --coverage-text; else echo --no-coverage; fi) -v + if [ -n "$LOG_COVERAGE" ]; then mv coverage/phpunit.cov coverage/phpunit-mariadb.cov; fi + + - name: "Run tests: PostgreSQL" + env: + DB_DSN: "pgsql:host=postgres;dbname=atk4_test" + DB_USER: atk4_test_user + DB_PASSWORD: atk4_pass + run: | + php demos/_demo-data/create-db.php + php -d opcache.enable_cli=1 vendor/bin/phpunit --exclude-group none $(if [ -n "$LOG_COVERAGE" ]; then echo --coverage-text; else echo --no-coverage; fi) -v + if [ -n "$LOG_COVERAGE" ]; then mv coverage/phpunit.cov coverage/phpunit-postgres.cov; fi + + - name: "Run tests: MSSQL" + env: + DB_DSN: "sqlsrv:Server=mssql;Database=master" + DB_USER: sa + DB_PASSWORD: atk4_pass + run: | + php demos/_demo-data/create-db.php + php -d opcache.enable_cli=1 vendor/bin/phpunit --exclude-group none $(if [ -n "$LOG_COVERAGE" ]; then echo --coverage-text; else echo --no-coverage; fi) -v + if [ -n "$LOG_COVERAGE" ]; then mv coverage/phpunit.cov coverage/phpunit-mssql.cov; fi + + - name: "Run tests: Oracle" + env: + DB_DSN: "oci:dbname=oracle/xe;charset=UTF8" + DB_USER: system + DB_PASSWORD: oracle + run: | + php demos/_demo-data/create-db.php + php -d opcache.enable_cli=1 vendor/bin/phpunit --exclude-group none $(if [ -n "$LOG_COVERAGE" ]; then echo --coverage-text; else echo --no-coverage; fi) -v + if [ -n "$LOG_COVERAGE" ]; then mv coverage/phpunit.cov coverage/phpunit-oracle.cov; fi + + - name: Upload coverage logs 1/2 (only for latest Phpunit) + if: env.LOG_COVERAGE + run: | + ls -l coverage | wc -l + php -d memory_limit=2G vendor/bin/phpcov merge coverage/ --clover coverage/merged.xml + + - name: Upload coverage logs 2/2 (only for latest Phpunit) if: env.LOG_COVERAGE uses: codecov/codecov-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} - file: build/logs/clover.xml + file: coverage/merged.xml behat-test: name: Behat runs-on: ubuntu-latest container: - image: atk4/image:${{ matrix.php }} + image: ghcr.io/mvorisek/image-php:${{ matrix.php }}-node strategy: fail-fast: false matrix: - php: ['latest-npm'] - type: ['Chrome', 'Firefox', 'Chrome Lowest', 'Chrome Slow'] + php: ['7.4', '8.0'] + type: ['Chrome', 'Chrome Lowest'] + include: + - php: 'latest' + type: 'Firefox' + - php: 'latest' + type: 'Chrome Slow' env: - LOG_COVERAGE: '' + LOG_COVERAGE: "${{ fromJSON('{true: \"1\", false: \"\"}')[matrix.php == '8.0' && matrix.type == 'Chrome' && (github.event_name == 'pull_request' || (github.event_name == 'push' && (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master')))] }}" services: + mysql: + image: mysql:8 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 -e MYSQL_ROOT_PASSWORD=atk4_pass_root -e MYSQL_USER=atk4_test_user -e MYSQL_PASSWORD=atk4_pass -e MYSQL_DATABASE=atk4_test --entrypoint sh mysql:8 -c "exec docker-entrypoint.sh mysqld --default-authentication-plugin=mysql_native_password" + mariadb: + image: mariadb + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 -e MYSQL_ROOT_PASSWORD=atk4_pass_root -e MYSQL_USER=atk4_test_user -e MYSQL_PASSWORD=atk4_pass -e MYSQL_DATABASE=atk4_test + postgres: + image: postgres:12-alpine + env: + POSTGRES_USER: atk4_test_user + POSTGRES_PASSWORD: atk4_pass + POSTGRES_DB: atk4_test + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + mssql: + image: mcr.microsoft.com/mssql/server + env: + ACCEPT_EULA: Y + SA_PASSWORD: atk4_pass + oracle: + image: ghcr.io/mvorisek/docker-oracle-xe-11g + env: + ORACLE_ALLOW_REMOTE: true selenium-chrome: image: selenium/standalone-chrome:latest options: --health-cmd "/opt/bin/check-grid.sh" @@ -170,36 +273,120 @@ jobs: echo "::set-output name=dir::$(composer config cache-files-dir)" - name: Setup cache 2/2 - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.type }}-${{ hashFiles('composer.json') }} + key: ${{ runner.os }}-composer-behat-${{ matrix.php }}-${{ matrix.type }}-${{ hashFiles('composer.json') }} restore-keys: | ${{ runner.os }}-composer- + - name: Install JS dependencies (only for Slow) + if: matrix.type == 'Chrome Slow' + run: | + npm install --loglevel=error -g pug-cli + + - name: Build/diff HTML files (only for Slow) + if: matrix.type == 'Chrome Slow' + run: | + for f in $(find template demos -name '*.pug' -o -name '*.html'); do + fpug=${f/.[a-z]*/.pug} + fhtml=${fpug/.pug/.html} + mv "$fhtml" "$fhtml.orig" + pug --silent --pretty "$fpug" + diff "$fhtml.orig" "$fhtml" + rm "$fhtml.orig" + done + - name: Install PHP dependencies run: | - composer remove --no-interaction --no-update phpunit/phpunit johnkary/phpunit-speedtrap phpunit/phpcov --dev + composer remove --no-interaction --no-update phpunit/phpunit johnkary/phpunit-speedtrap --dev composer remove --no-interaction --no-update friendsofphp/php-cs-fixer --dev composer remove --no-interaction --no-update phpstan/phpstan --dev composer update --ansi --prefer-dist --no-interaction --no-progress --optimize-autoloader - if [ "${{ matrix.type }}" == "Chrome Lowest" ]; then composer update --ansi --prefer-dist --prefer-lowest --prefer-stable --no-interaction --no-progress --optimize-autoloader ; fi + if [ "${{ matrix.type }}" == "Chrome Lowest" ]; then composer update --ansi --prefer-dist --prefer-lowest --prefer-stable --no-interaction --no-progress --optimize-autoloader; fi - name: Init run: | - mkdir -p build/logs - php demos/_demo-data/create-sqlite-db.php - - - name: "Run tests: Behat" - run: | + php -r '(new PDO("mysql:host=mysql", "root", "atk4_pass_root"))->exec("ALTER USER '"'"'atk4_test_user'"'"'@'"'"'%'"'"' WITH MAX_USER_CONNECTIONS 5");' + php -r '(new PDO("mysql:host=mariadb", "root", "atk4_pass_root"))->exec("ALTER USER '"'"'atk4_test_user'"'"'@'"'"'%'"'"' WITH MAX_USER_CONNECTIONS 5");' + php -r '(new PDO("pgsql:host=postgres;dbname=atk4_test", "atk4_test_user", "atk4_pass"))->exec("ALTER ROLE atk4_test_user CONNECTION LIMIT 1");' + if [ -n "$LOG_COVERAGE" ]; then mkdir coverage && cp tools/CoverageUtil.php demos; fi + sed -E "s/\(('sqlite:.+)\);/(\$_ENV['DB_DSN'] ?? \\1, \$_ENV['DB_USER'] ?? null, \$_ENV['DB_PASSWORD'] ?? null);/g" -i demos/db.default.php + sed -i "s~'https://raw.githack.com/atk4/ui/develop/public.*~'/public',~" vendor/atk4/ui/src/App.php php -S 172.18.0.2:8888 > /dev/null 2>&1 & - sleep 1 - if [ "${{ matrix.type }}" == "Firefox" ]; then sed -i "s~chrome~firefox~" behat.yml.dist ; fi - if [ "${{ matrix.type }}" == "Chrome Slow" ]; then echo 'sleep(1);' >> demos/init-app.php ; fi + sleep 0.2 + if [ "${{ matrix.type }}" == "Firefox" ]; then sed -i "s~chrome~firefox~" behat.yml.dist; fi + if [ "${{ matrix.type }}" == "Chrome Slow" ]; then echo 'sleep(1);' >> demos/init-app.php; fi # remove once https://github.com/minkphp/Mink/pull/801 # and https://github.com/minkphp/MinkSelenium2Driver/pull/322 are released sed -i 's/usleep(100000)/usleep(5000)/' vendor/behat/mink/src/Element/Element.php sed -i 's/usleep(100000)/usleep(5000)/' vendor/behat/mink-selenium2-driver/src/Selenium2Driver.php + - name: "Run tests: SQLite" + run: | + php demos/_demo-data/create-db.php + vendor/bin/behat -vv --config behat.yml.dist + + - name: "Run tests: MySQL (only for latest Chrome or cron)" + if: env.LOG_COVERAGE || github.event_name == 'schedule' + env: + DB_DSN: "mysql:host=mysql;dbname=atk4_test" + DB_USER: atk4_test_user + DB_PASSWORD: atk4_pass + run: | + php demos/_demo-data/create-db.php + vendor/bin/behat -vv --config behat.yml.dist + + - name: "Run tests: MariaDB (only for latest Chrome or cron)" + if: env.LOG_COVERAGE || github.event_name == 'schedule' + env: + DB_DSN: "mysql:host=mariadb;dbname=atk4_test" + DB_USER: atk4_test_user + DB_PASSWORD: atk4_pass + run: | + php demos/_demo-data/create-db.php + vendor/bin/behat -vv --config behat.yml.dist + + - name: "Run tests: PostgreSQL (only for latest Chrome or cron)" + if: env.LOG_COVERAGE || github.event_name == 'schedule' + env: + DB_DSN: "pgsql:host=postgres;dbname=atk4_test" + DB_USER: atk4_test_user + DB_PASSWORD: atk4_pass + run: | + php demos/_demo-data/create-db.php + vendor/bin/behat -vv --config behat.yml.dist + + - name: "Run tests: MSSQL (only for latest Chrome or cron)" + if: env.LOG_COVERAGE || github.event_name == 'schedule' + env: + DB_DSN: "sqlsrv:Server=mssql;Database=master" + DB_USER: sa + DB_PASSWORD: atk4_pass + run: | + php demos/_demo-data/create-db.php vendor/bin/behat -vv --config behat.yml.dist + + - name: "Run tests: Oracle (only for latest Chrome or cron)" + if: env.LOG_COVERAGE || github.event_name == 'schedule' + env: + DB_DSN: "oci:dbname=oracle/xe;charset=UTF8" + DB_USER: system + DB_PASSWORD: oracle + run: | + php demos/_demo-data/create-db.php + vendor/bin/behat -vv --config behat.yml.dist + + - name: Upload coverage logs 1/2 (only for latest Chrome) + if: env.LOG_COVERAGE + run: | + ls -l coverage | wc -l + php -d memory_limit=2G vendor/bin/phpcov merge coverage/ --clover coverage/merged.xml + + - name: Upload coverage logs 2/2 (only for latest Chrome) + if: env.LOG_COVERAGE + uses: codecov/codecov-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: coverage/merged.xml diff --git a/.gitignore b/.gitignore index 3894b69a..05673326 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,22 @@ -docs/build -/build +/docs/build +/coverage /vendor /composer.lock .idea nbproject +.vscode .DS_Store local *.local *.local.* +cache *.cache *.cache.* +/demos/db.php +/demos/_demo-data/db.sqlite +/demos/_demo-data/db.sqlite-journal /phpunit.xml -/phpunit-*.xml /behat.yml -*.bak -/demos/data/db.sqlite diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index aad5eedb..f0a6b512 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,21 +1,20 @@ in([__DIR__]) ->exclude([ 'cache', 'build', 'vendor', - ]) - ->in(__DIR__) -; + ]); -$config = new PhpCsFixer\Config(); -$config->setRiskyAllowed(true) +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) ->setRules([ '@PhpCsFixer' => true, - '@PhpCsFixer:risky' =>true, - '@PHP71Migration:risky' => true, - '@PHP73Migration' => true, + '@PhpCsFixer:risky' => true, + '@PHP74Migration:risky' => true, + '@PHP74Migration' => true, // required by PSR-12 'concat_space' => [ @@ -37,10 +36,8 @@ 'equal' => false, 'identical' => false, ], + 'native_constant_invocation' => true, 'native_function_invocation' => false, - 'non_printable_character' => [ - 'use_escape_sequences_in_strings' => true, - ], 'void_return' => false, 'blank_line_before_statement' => [ 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'exit'], @@ -58,15 +55,16 @@ 'phpdoc_add_missing_param_annotation' => false, 'return_assignment' => false, 'comment_to_phpdoc' => false, - 'list_syntax' => ['syntax' => 'short'], 'general_phpdoc_annotation_remove' => [ 'annotations' => ['author', 'copyright', 'throws'], ], 'nullable_type_declaration_for_default_null_value' => [ 'use_nullable_type_declaration' => false, ], + + // fn => without curly brackets is less readable, + // also prevent bounding of unwanted variables for GC + 'use_arrow_functions' => false, ]) ->setFinder($finder) - ->setCacheFile(__DIR__ . '/.php_cs.cache'); - -return $config; \ No newline at end of file + ->setCacheFile(sys_get_temp_dir() . '/php-cs-fixer.' . md5(__DIR__) . '.cache'); diff --git a/README.md b/README.md index 76c2f660..05a00624 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Here are all the classes implemented: - Populates user menu with name of current user - Adds log-out link - Adds Preferences page -- [Flexible ACL support](docs/acl.md) +- Flexible ACL support - Field\Password - password hashing, safety, generation and validation - Model\User - basic user entity that can be extended - LoginForm - username/password login form diff --git a/behat.yml.dist b/behat.yml.dist index 9d993570..2b4c2157 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -4,12 +4,11 @@ default: paths: features: '%paths.base%/tests-behat' contexts: - - Atk4\Login\Behat\Context - Behat\MinkExtension\Context\MinkContext + - Atk4\Ui\Behat\Context extensions: Behat\MinkExtension: - show_cmd: 'open %s' - base_url: 'http://172.18.0.2:8888/demos' + base_url: 'http://172.18.0.2:8888/demos' sessions: default: selenium2: @@ -20,4 +19,4 @@ default: chrome: args: - '--headless' - - '--window-size=1930,1200' \ No newline at end of file + - '--window-size=1920,1200' diff --git a/codecov.yml b/codecov.yml index f195eeb4..98b05a47 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,15 +1,13 @@ ignore: + - src/Behat - demos - docs - - template - - tests - - tests-behat comment: false coverage: status: project: default: target: auto - threshold: 0.1 + threshold: 0.025 patch: false changes: false diff --git a/composer.json b/composer.json index 4e398efb..bd1c5f2e 100644 --- a/composer.json +++ b/composer.json @@ -1,65 +1,63 @@ { - "name": "atk4/login", - "type": "library", - "description": "Login and User module for Agile UI", - "keywords": [ - "user", - "acl", - "auth", - "login", - "atk4", - "agile", - "agile ui", - "data", - "framework" - ], - "homepage": "https://github.com/atk4/login", - "license": "MIT", - "authors": [ - { - "name": "Romans Malinovskis", - "email": "romans@agiletoolkit.org", - "homepage": "https://nearly.guru/" - } - ], - "minimum-stability": "dev", - "prefer-stable": true, - "config": { - "sort-packages": true - }, - "require": { - "php": ">=7.3.0", - "atk4/ui": "2.4.*", - "atk4/data": "2.4.*" - }, - "require-release": { - "php": ">=7.3.0", - "atk4/ui": "~2.4.0" - }, - "require-dev": { - "behat/behat": "^3.8", - "behat/mink": "^1.8", - "behat/mink-extension": "^2.3.1", - "behat/mink-selenium2-driver": "^1.4", - "ergebnis/composer-normalize": "^2.13", - "friendsofphp/php-cs-fixer": "^2.17", - "johnkary/phpunit-speedtrap": "^3.2", - "instaclick/php-webdriver": "^1.4.7", - "phpstan/phpstan": "^0.12.82", - "phpunit/phpcov": "*", - "phpunit/phpunit": ">=9.3", - "symfony/contracts": ">=1.1" - }, - "autoload": { - "psr-4": { - "Atk4\\Login\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Atk4\\Login\\Behat\\": "tests-behat/Bootstrap/", - "Atk4\\Login\\Demo\\": "demos/src/", - "Atk4\\Login\\Tests\\": "tests/" - } - } + "name": "atk4/login", + "type": "library", + "description": "Login and User module for Agile UI", + "keywords": [ + "user", + "acl", + "auth", + "login", + "atk4", + "agile", + "agile ui", + "data", + "framework" + ], + "homepage": "https://github.com/atk4/login", + "license": "MIT", + "authors": [ + { + "name": "Romans Malinovskis", + "email": "romans@agiletoolkit.org", + "homepage": "https://nearly.guru/" + }, + { + "name": "Michael Voříšek", + "homepage": "https://mvorisek.cz/" + } + ], + "require": { + "php": ">=7.4 <8.2", + "atk4/ui": "~3.0.0" + }, + "require-dev": { + "behat/behat": "^3.8.2 || dev-master#6f38d11", + "behat/gherkin": "^4.8.1 || dev-master#5fbf806", + "behat/mink": "^1.8.2 || dev-master#1ab79d6", + "behat/mink-extension": "^2.3.1", + "behat/mink-selenium2-driver": "^1.4", + "ergebnis/composer-normalize": "^2.13", + "friendsofphp/php-cs-fixer": "^3.0", + "instaclick/php-webdriver": "^1.4.7", + "johnkary/phpunit-speedtrap": "^3.3", + "phpstan/phpstan": "^0.12.82", + "phpunit/phpcov": "*", + "phpunit/phpunit": "^9.5.5", + "symfony/contracts": ">=1.1" + }, + "config": { + "sort-packages": true + }, + "autoload": { + "psr-4": { + "Atk4\\Login\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Atk4\\Login\\Tests\\": "tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/demos/_demo-data/create-sqlite-db.php b/demos/_demo-data/create-db.php similarity index 75% rename from demos/_demo-data/create-sqlite-db.php rename to demos/_demo-data/create-db.php index 40e79a79..72fd2745 100644 --- a/demos/_demo-data/create-sqlite-db.php +++ b/demos/_demo-data/create-db.php @@ -4,34 +4,40 @@ namespace Atk4\Login\Demos; -include __DIR__ . '/../../vendor/autoload.php'; +use Atk4\Data\Model; +use Atk4\Schema\Migration; -$sqliteFile = __DIR__ . '/../data/db.sqlite'; -if (file_exists($sqliteFile)) { - unlink($sqliteFile); +require_once __DIR__ . '/../init-autoloader.php'; + +$sqliteFile = __DIR__ . '/db.sqlite'; +if (!file_exists($sqliteFile)) { + new \Atk4\Data\Persistence\Sql('sqlite:' . $sqliteFile); } +unset($sqliteFile); + +/** @var \Atk4\Data\Persistence\Sql $db */ +require_once __DIR__ . '/../init-db.php'; -$persistence = new \Atk4\Data\Persistence\Sql('sqlite:' . $sqliteFile); -$model = new \Atk4\Data\Model($persistence, ['table' => 'login_user']); +$model = new Model($db, ['table' => 'login_user']); $model->addField('name', ['type' => 'string']); $model->addField('email', ['type' => 'string']); $model->addField('password', ['type' => 'string']); $model->addField('role_id', ['type' => 'integer']); -(new \Atk4\Schema\Migration($model))->dropIfExists()->create(); +(new Migration($model))->create(); $model->import([ 1 => ['id' => 1, 'name' => 'Standard User', 'email' => 'user', 'password' => '$2y$10$BwEhcP8f15yOexf077VTHOnySn/mit49ZhpfeBkORQhrsmHr4U6Qy', 'role_id' => 1], // user/user 2 => ['id' => 2, 'name' => 'Administrator', 'email' => 'admin', 'password' => '$2y$10$p34ciRcg9GZyxukkLIaEnenGBao79fTFa4tFSrl7FvqrxnmEGlD4O', 'role_id' => 2], // admin/admin ]); -$model = new \Atk4\Data\Model($persistence, ['table' => 'login_role']); +$model = new Model($db, ['table' => 'login_role']); $model->addField('name', ['type' => 'string']); -(new \Atk4\Schema\Migration($model))->dropIfExists()->create(); +(new Migration($model))->create(); $model->import([ 1 => ['id' => 1, 'name' => 'User Role'], 2 => ['id' => 2, 'name' => 'Admin Role'], ]); -$model = new \Atk4\Data\Model($persistence, ['table' => 'login_access_role']); +$model = new Model($db, ['table' => 'login_access_role']); $model->addField('role_id', ['type' => 'integer']); $model->addField('model', ['type' => 'string']); $model->addField('all_visible', ['type' => 'boolean']); @@ -42,11 +48,11 @@ $model->addField('actions', ['type' => 'boolean']); $model->addField('conditions', ['type' => 'boolean']); -(new \Atk4\Schema\Migration($model))->dropIfExists()->create(); +(new Migration($model))->create(); $model->import([ 1 => ['id' => 1, 'role_id' => 1, 'model' => '\\Atk4\Login\\Model\\User', 'all_visible' => 1, 'visible_fields' => null, 'all_editable' => 0, 'editable_fields' => null, 'all_actions' => 1, 'actions' => null, 'conditions' => null], 2 => ['id' => 2, 'role_id' => 2, 'model' => '\\Atk4\Login\\Model\\User', 'all_visible' => 1, 'visible_fields' => null, 'all_editable' => 1, 'editable_fields' => null, 'all_actions' => 1, 'actions' => null, 'conditions' => null], 3 => ['id' => 3, 'role_id' => 2, 'model' => '\\Atk4\Login\\Model\\Role', 'all_visible' => 1, 'visible_fields' => null, 'all_editable' => 1, 'editable_fields' => null, 'all_actions' => 1, 'actions' => null, 'conditions' => null], ]); -echo 'import complete!' . "\n"; +echo 'import complete!' . "\n\n"; diff --git a/demos/src/App.php b/demos/_includes/App.php similarity index 96% rename from demos/src/App.php rename to demos/_includes/App.php index 277af96a..05e58f75 100644 --- a/demos/src/App.php +++ b/demos/_includes/App.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Atk4\Login\Demo; +namespace Atk4\Login\Demos; use Atk4\Login\Acl; use Atk4\Login\Auth; @@ -11,7 +11,7 @@ /** * Example implementation of your Authenticated application. */ -class App extends AbstractApp +class App extends \Atk4\Ui\App { public $auth; public $title = 'Demo App'; diff --git a/demos/src/MigratorConsole.php b/demos/_includes/MigratorConsole.php similarity index 98% rename from demos/src/MigratorConsole.php rename to demos/_includes/MigratorConsole.php index d0e1f946..1492e9b7 100644 --- a/demos/src/MigratorConsole.php +++ b/demos/_includes/MigratorConsole.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Atk4\Login\Demo; +namespace Atk4\Login\Demos; use Atk4\Core\AppScopeTrait; use Atk4\Core\DynamicMethodTrait; diff --git a/demos/src/Model/Client.php b/demos/_includes/Model/Client.php similarity index 94% rename from demos/src/Model/Client.php rename to demos/_includes/Model/Client.php index 5f5689a9..ddb536d2 100644 --- a/demos/src/Model/Client.php +++ b/demos/_includes/Model/Client.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Atk4\Login\Demo\Model; +namespace Atk4\Login\Demos\Model; use Atk4\Data\Model; diff --git a/demos/acl-clients.php b/demos/acl-clients.php index 6989315e..9d8cf939 100644 --- a/demos/acl-clients.php +++ b/demos/acl-clients.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Atk4\Login\Demo; +namespace Atk4\Login\Demos; use Atk4\Ui\Crud; use Atk4\Ui\Header; diff --git a/demos/admin-roles.php b/demos/admin-roles.php index 9b4a978f..a006f178 100644 --- a/demos/admin-roles.php +++ b/demos/admin-roles.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Atk4\Login\Demo; +namespace Atk4\Login\Demos; use Atk4\Login\Model\Role; use Atk4\Login\RoleAdmin; diff --git a/demos/admin-setup.php b/demos/admin-setup.php index 7d40ce62..f43b1bc2 100644 --- a/demos/admin-setup.php +++ b/demos/admin-setup.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Atk4\Login\Demo; +namespace Atk4\Login\Demos; -use Atk4\Login\Demo\Model\Client; +use Atk4\Login\Demos\Model\Client; use Atk4\Login\Model\AccessRule; use Atk4\Login\Model\Role; use Atk4\Login\Model\User; diff --git a/demos/admin-users.php b/demos/admin-users.php index 7daf12e7..4effc9f2 100644 --- a/demos/admin-users.php +++ b/demos/admin-users.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Atk4\Login\Demo; +namespace Atk4\Login\Demos; use Atk4\Login\Model\User; use Atk4\Login\UserAdmin; diff --git a/demos/data/.keep b/demos/data/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/demos/db.default.php b/demos/db.default.php new file mode 100644 index 00000000..638567eb --- /dev/null +++ b/demos/db.default.php @@ -0,0 +1,18 @@ + $app->auth]); $m = new User($app->db); -$f->setModel($m); +$f->setModel($m->createEntity()); diff --git a/demos/index.php b/demos/index.php index c252745a..54a85f2a 100644 --- a/demos/index.php +++ b/demos/index.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Atk4\Login\Demo; +namespace Atk4\Login\Demos; use Atk4\Ui\Button; use Atk4\Ui\Header; diff --git a/demos/init-app.php b/demos/init-app.php index 8ef3e97c..43252736 100644 --- a/demos/init-app.php +++ b/demos/init-app.php @@ -2,10 +2,21 @@ declare(strict_types=1); -namespace Atk4\Login\Demo; +namespace Atk4\Login\Demos; -include __DIR__ . '/../vendor/autoload.php'; +date_default_timezone_set('UTC'); + +require_once __DIR__ . '/init-autoloader.php'; -// init App $app = new App(); + +try { + /** @var \Atk4\Data\Persistence\Sql $db */ + require_once __DIR__ . '/init-db.php'; + $app->db = $db; + unset($db); +} catch (\Throwable $e) { + throw new \Atk4\Ui\Exception('Database error: ' . $e->getMessage()); +} + $app->invokeInit(); diff --git a/demos/init-autoloader.php b/demos/init-autoloader.php new file mode 100644 index 00000000..8cf7836c --- /dev/null +++ b/demos/init-autoloader.php @@ -0,0 +1,15 @@ +setClassMapAuthoritative(false); +$loader->setPsr4('Atk4\Login\Demos\\', __DIR__ . '/_includes'); +unset($isRootProject, $loader); diff --git a/demos/init-db.php b/demos/init-db.php new file mode 100644 index 00000000..805d948d --- /dev/null +++ b/demos/init-db.php @@ -0,0 +1,15 @@ +addMoreInfo('PDO error', $e->getMessage()); +} diff --git a/demos/src/AbstractApp.php b/demos/src/AbstractApp.php deleted file mode 100644 index 580382a3..00000000 --- a/demos/src/AbstractApp.php +++ /dev/null @@ -1,28 +0,0 @@ -dbFile = __DIR__ . '/..' . $this->dbFile; - $this->db = new \Atk4\Data\Persistence\Sql('sqlite:' . $this->dbFile); - } -} diff --git a/docs/acl.md b/docs/acl.md deleted file mode 100644 index e69de29b..00000000 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index f63fbc28..0b3c26ff 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -2,7 +2,7 @@ includes: - vendor/mahalux/atk4-hintable/phpstan-ext.neon parameters: - level: 4 + level: 4 # should be 6 paths: - ./ excludes_analyse: @@ -13,12 +13,23 @@ parameters: # TODO review once we drop PHP 7.x support treatPhpDocTypesAsCertain: false + # some extra rules + checkAlwaysTrueCheckTypeFunctionCall: true + checkAlwaysTrueInstanceof: true + checkAlwaysTrueStrictComparison: true + checkExplicitMixedMissingReturn: true + checkFunctionNameCase: true + # TODO checkMissingClosureNativeReturnTypehintRule: true + reportMaybesInMethodSignatures: true + reportStaticMethodSignatures: true + checkTooWideReturnTypesInProtectedAndPublicMethods: true + checkMissingIterableValueType: false # TODO + ignoreErrors: - '~^Unsafe usage of new static\(\)\.$~' # TODO these rules are generated, this ignores should be fixed in the code # level 0 - - message: "#^Instantiated class Atk4\\\\Data\\\\Model\\\\AccessRule not found\\.$#" count: 1 @@ -29,12 +40,10 @@ parameters: message: "#^Call to an undefined method Atk4\\\\Login\\\\Model\\\\AccessRule\\:\\:setUnique\\(\\)\\.$#" count: 2 path: src/Model/AccessRule.php - - message: "#^Method Atk4\\\\Ui\\\\View\\:\\:setModel\\(\\) invoked with 2 parameters, 1 required\\.$#" count: 1 path: src/Form/Control/Actions.php - - message: "#^Method Atk4\\\\Ui\\\\View\\:\\:setModel\\(\\) invoked with 2 parameters, 1 required\\.$#" count: 1 @@ -44,58 +53,47 @@ parameters: - message: "#^Access to an undefined property Atk4\\\\Ui\\\\Layout\\:\\:\\$menuLeft\\.$#" count: 5 - path: demos/src/App.php - + path: demos/_includes/App.php - message: "#^Call to an undefined method Atk4\\\\Ui\\\\AbstractView\\:\\:getUrl\\(\\)\\.$#" count: 1 path: src/Auth.php - - message: "#^Method Atk4\\\\Login\\\\Form\\\\Control\\\\Fields\\:\\:setModel\\(\\) should return Atk4\\\\Data\\\\Model but return statement is missing\\.$#" count: 1 path: src/Form/Control/Fields.php - - message: "#^Call to an undefined method Atk4\\\\Ui\\\\Form\\\\Control\\:\\:addAction\\(\\)\\.$#" count: 1 path: src/Form/Login.php - - message: "#^Call to an undefined method Atk4\\\\Ui\\\\Form\\\\Control\\:\\:setInputAttr\\(\\)\\.$#" count: 2 path: src/Form/Register.php - - message: "#^Call to an undefined method Atk4\\\\Data\\\\Reference\\\\HasOne\\:\\:withTitle\\(\\)\\.$#" count: 1 path: src/Model/AccessRule.php - - message: "#^Call to an undefined method Atk4\\\\Data\\\\Reference\\\\HasOne\\:\\:withTitle\\(\\)\\.$#" count: 1 path: src/Model/User.php - - message: "#^Call to an undefined method Atk4\\\\Ui\\\\Table\\\\Column\\:\\:addModal\\(\\)\\.$#" count: 1 path: src/RoleAdmin.php - - message: "#^Call to method addCondition\\(\\) on an unknown class Atk4\\\\Data\\\\Model\\\\AccessRule\\.$#" count: 1 path: src/RoleAdmin.php - - message: "#^Call to an undefined method Atk4\\\\Ui\\\\Table\\\\Column\\:\\:addModal\\(\\)\\.$#" count: 1 path: src/UserAdmin.php - - message: "#^Call to an undefined method Atk4\\\\Data\\\\Field\\:\\:suggestPassword\\(\\)\\.$#" count: 1 path: src/UserAdmin.php - - message: "#^Call to an undefined method Atk4\\\\Data\\\\Field\\:\\:verify\\(\\)\\.$#" count: 7 @@ -106,12 +104,10 @@ parameters: message: "#^Method Atk4\\\\Login\\\\Acl\\:\\:getRules\\(\\) should return Atk4\\\\Login\\\\Model\\\\AccessRule but returns Atk4\\\\Data\\\\Model\\.$#" count: 1 path: src/Acl.php - - message: "#^Property Atk4\\\\Ui\\\\App\\:\\:\\$html \\(Atk4\\\\Ui\\\\View\\) does not accept null\\.$#" count: 1 path: src/Auth.php - - message: "#^Method Atk4\\\\Login\\\\Form\\\\Control\\\\Generic\\:\\:getModel\\(\\) should return Atk4\\\\Data\\\\Model\\|null but empty return statement found\\.$#" count: 2 @@ -119,16 +115,10 @@ parameters: # level 4 - - message: "#^Else branch is unreachable because previous condition is always true\\.$#" + message: "#^If condition is always true\\.$#" count: 1 - path: src/Field/Password.php - + path: src/Form/Login.php - - message: "#^Else branch is unreachable because ternary operator condition is always true\\.$#" + message: "#^Access to an undefined property Atk4\\\\Data\\\\Field\\:\\:\\$passwordHash\\.$#" count: 1 path: src/Field/Password.php - - - - message: "#^If condition is always true\\.$#" - count: 1 - path: src/Form/Login.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 169ba8f8..04575006 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,23 +1,18 @@ - - - - - src - - - - - - - - - - - - - - tests - - - + + + + tests + + + + + + + + src + + + + + diff --git a/src/Auth.php b/src/Auth.php index 13cefc17..acc6ab51 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -14,7 +14,9 @@ use Atk4\Data\Model; use Atk4\Data\Persistence; use Atk4\Login\Cache\Session; +use Atk4\Login\Field\Password; use Atk4\Login\Layout\Narrow; +use Atk4\Login\Model\User; use Atk4\Ui\Layout\Admin; use Atk4\Ui\VirtualPage; @@ -172,7 +174,7 @@ protected function init(): void */ public function setModel($model, string $fieldLogin = null, string $fieldPassword = null) { - $this->user = $model; + $this->user = $model->createEntity(); if ($fieldLogin !== null) { $this->fieldLogin = $fieldLogin; @@ -232,25 +234,26 @@ public function tryLogin(string $email, string $password): bool // first logout $this->logout(); - $user = new $this->user($this->user->persistence); + /** @var User $userModel */ + $userModel = new $this->user($this->user->persistence); - $user->tryLoadBy($this->fieldLogin, $email); - if ($user->loaded()) { + $userEntity = $userModel->tryLoadBy($this->fieldLogin, $email); + if ($userEntity->loaded()) { // verify if the password matches - $pw_field = $user->getField($this->fieldPassword); + $pw_field = $userEntity->getField($this->fieldPassword); if (method_exists($pw_field, 'verify') && $pw_field->verify($password)) { - $this->hook(self::HOOK_LOGGED_IN, [$user]); + $this->hook(self::HOOK_LOGGED_IN, [$userEntity]); // save user record in cache if ($this->cacheEnabled) { - $this->cache->setData($user->get()); + $this->cache->setData($userEntity->get()); $this->loadFromCache(); } else { - $this->user = clone $user; + $this->user = clone $userEntity; } return true; } - $user->unload(); + $userEntity->unload(); $this->hook(self::HOOK_BAD_LOGIN, [$email]); } @@ -280,7 +283,7 @@ public function logout(): void */ public function setAcl(Acl $acl, Persistence $persistence = null) { - $persistence = $persistence ?? $this->user->persistence; + $persistence ??= $this->user->persistence; $acl->auth = $this; $acl->applyRestrictions($this->user->persistence, $this->user); diff --git a/src/Feature/PasswordManagement.php b/src/Feature/PasswordManagement.php index 9ed8f9de..fd7c1358 100644 --- a/src/Feature/PasswordManagement.php +++ b/src/Feature/PasswordManagement.php @@ -227,7 +227,7 @@ private function calculate_strength(string $pw): int if ($length > 2) { // consecutive letters and numbers foreach (['/[a-z]{2,}/', '/[A-Z]{2,}/', '/[0-9]{2,}/'] as $re) { - preg_match_all($re, $pw, $matches, PREG_SET_ORDER); + preg_match_all($re, $pw, $matches, \PREG_SET_ORDER); if (!empty($matches)) { foreach ($matches as $match) { $score -= (strlen($match[0]) - 1) * 2; diff --git a/src/Feature/SendEmailAction.php b/src/Feature/SendEmailAction.php index fbe8fe43..032b0923 100644 --- a/src/Feature/SendEmailAction.php +++ b/src/Feature/SendEmailAction.php @@ -38,8 +38,8 @@ public function initSendEmailAction(): UserAction public function sendEmail(string $subject, string $message): bool { $to = $this->get('email'); - $message = str_replace(["\r\n", "\r", "\n"], PHP_EOL, $message); - $message = wordwrap($message, 70, PHP_EOL); + $message = str_replace(["\r\n", "\r", "\n"], \PHP_EOL, $message); + $message = wordwrap($message, 70, \PHP_EOL); return mail($to, $subject, $message); } diff --git a/src/Feature/UniqueFieldValue.php b/src/Feature/UniqueFieldValue.php index 5b0c7fca..9494ac3d 100644 --- a/src/Feature/UniqueFieldValue.php +++ b/src/Feature/UniqueFieldValue.php @@ -21,10 +21,10 @@ public function setUnique(string $field) { $this->onHook(Model::HOOK_BEFORE_SAVE, function ($m) use ($field) { if ($m->isDirty($field)) { - $a = new static($m->persistence); - $a->addCondition($a->id_field, '!=', $m->getId()); - $a->tryLoadBy($field, $m->get($field)); - if ($a->loaded()) { + $model = new static($m->persistence); + $model->addCondition($model->id_field, '!=', $m->getId()); + $entity = $model->tryLoadBy($field, $m->get($field)); + if ($entity->loaded()) { throw new ValidationException([$field => ucwords($field) . ' with such value already exists'], $this); } } diff --git a/src/Field/Password.php b/src/Field/Password.php index d27be83c..dbb8bea4 100644 --- a/src/Field/Password.php +++ b/src/Field/Password.php @@ -7,6 +7,7 @@ use Atk4\Core\InitializerTrait; use Atk4\Data\Exception; use Atk4\Data\Field; +use Atk4\Data\Model; use Atk4\Data\Persistence; use Atk4\Ui\Persistence\Ui; @@ -32,7 +33,7 @@ class Password extends Field * Use it if you need to customize your password encryption algorithm. * Receives parameters - plaintext password. * - * @var callable + * @var callable|null */ public $encryptMethod; @@ -41,7 +42,7 @@ class Password extends Field * Use it if you need to customize your password verification algorithm. * Receives parameters - plaintext password, encrypted password. * - * @var callable + * @var callable|null */ public $verifyMethod; @@ -52,6 +53,16 @@ protected function init(): void { $this->_init(); $this->setDefaultTypecastMethods(); + $this->getOwner()->onHook(Model::HOOK_AFTER_LOAD, function (Model $m) { + /** @var Password $modelField */ + $modelField = $m->getModel()->getField($this->short_name); + $m->getField($this->short_name)->passwordHash = $modelField->passwordHash; + }); + $this->getOwner()->onHook(Model::HOOK_AFTER_UNLOAD, function (Model $m) { + /** @var Password $modelField */ + $modelField = $m->getModel()->getField($this->short_name); + $modelField->passwordHash = null; + }); } /** @@ -81,20 +92,6 @@ function (?string $password, Field $f, Persistence $p) { ]; } - /** - * Normalize password - remove hash. - * - * @param string $value password - * - * @return mixed - */ - public function normalize($value) - { - $this->passwordHash = null; - - return parent::normalize($value); - } - /** * DO NOT CALL THIS METHOD. It is automatically invoked when you save * your model. @@ -117,7 +114,7 @@ public function encrypt(?string $password, Field $f, Persistence $p) if (is_callable($this->encryptMethod)) { $this->passwordHash = call_user_func_array($this->encryptMethod, [$password]); } else { - $this->passwordHash = password_hash($password, PASSWORD_DEFAULT); + $this->passwordHash = password_hash($password, \PASSWORD_DEFAULT); } return $this->passwordHash; diff --git a/src/Form/Register.php b/src/Form/Register.php index d1527311..d88f6255 100644 --- a/src/Form/Register.php +++ b/src/Form/Register.php @@ -49,10 +49,9 @@ public function setModel(Model $user, $fields = null) // on form submit save new user in persistence $form->onSubmit(function ($form) { // Look if user already exist? - $c = clone $this->model; - $c->unload(); - $c->tryLoadBy($this->auth->fieldLogin, strtolower($form->model->get($this->auth->fieldLogin))); - if ($c->loaded()) { + $model = $this->model->getModel(); + $entity = $model->tryLoadBy($this->auth->fieldLogin, strtolower($form->model->get($this->auth->fieldLogin))); + if ($entity->loaded()) { return $form->error($this->auth->fieldLogin, 'User with this email already exist'); } diff --git a/template/all.html b/template/all.html index 3eb483d8..03093045 100644 --- a/template/all.html +++ b/template/all.html @@ -11,7 +11,7 @@

Welcome back!

- +
@@ -71,4 +71,4 @@

Sign up

Confirm your email address

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Sequi dolores, doloremque. Asperiores molestias mollitia similique facere.

- \ No newline at end of file + diff --git a/template/all.pug b/template/all.pug index 79e421af..9fa0aa58 100644 --- a/template/all.pug +++ b/template/all.pug @@ -63,3 +63,4 @@ section.ui.segments i.huge.circular.inverted.teal.envelope.icon h2 Confirm your email address p Lorem ipsum dolor sit amet, consectetur adipisicing elit. Sequi dolores, doloremque. Asperiores molestias mollitia similique facere. += "\n" diff --git a/template/layout/narrow.html b/template/layout/narrow.html index 4a94fb7f..fdb881eb 100644 --- a/template/layout/narrow.html +++ b/template/layout/narrow.html @@ -9,4 +9,4 @@

{$Content}
{$Segment} {/} - \ No newline at end of file + diff --git a/template/layout/narrow.pug b/template/layout/narrow.pug index e2b75a76..87e05218 100644 --- a/template/layout/narrow.pug +++ b/template/layout/narrow.pug @@ -10,3 +10,4 @@ .ui.segment.raised.very.padded {$Content} | {$Segment} | {/} += "\n" diff --git a/template/login.html b/template/login.html index 038d2a5a..336f5897 100644 --- a/template/login.html +++ b/template/login.html @@ -11,7 +11,7 @@

{title}Welcome back! {/}

- +
@@ -71,4 +71,4 @@

Sign up

Confirm your email address

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Sequi dolores, doloremque. Asperiores molestias mollitia similique facere.

- \ No newline at end of file + diff --git a/template/login.pug b/template/login.pug index 7505b2bc..db80661f 100644 --- a/template/login.pug +++ b/template/login.pug @@ -63,3 +63,4 @@ section.ui.segments i.huge.circular.inverted.teal.envelope.icon h2 Confirm your email address p Lorem ipsum dolor sit amet, consectetur adipisicing elit. Sequi dolores, doloremque. Asperiores molestias mollitia similique facere. += "\n" diff --git a/tests-behat/Bootstrap/Context.php b/tests-behat/Bootstrap/Context.php deleted file mode 100644 index 48bc8a80..00000000 --- a/tests-behat/Bootstrap/Context.php +++ /dev/null @@ -1,714 +0,0 @@ -getMink()->getSession($name); - } - - /** - * @BeforeStep - */ - public function closeAllToasts(BeforeStepScope $event): void - { - if (!$this->getSession()->getDriver()->isStarted()) { - return; - } - - if (strpos($event->getStep()->getText(), 'Toast display should contains text ') !== 0) { - $this->getSession()->executeScript('$(\'.toast-box > .ui.toast\').toast(\'close\');'); - } - } - - /** - * @AfterStep - */ - public function waitUntilLoadingAndAnimationFinished(AfterStepScope $event): void - { - $this->jqueryWait(); - $this->disableAnimations(); - $this->assertNoException(); - $this->disableDebounce(); - } - - protected function disableAnimations(): void - { - // disable all CSS/jQuery animations/transitions - $toCssFx = function ($selector, $cssPairs) { - $css = []; - foreach ($cssPairs as $k => $v) { - foreach ([$k, '-moz-' . $k, '-webkit-' . $k] as $k2) { - $css[] = $k2 . ': ' . $v . ' !important;'; - } - } - - return $selector . ' { ' . implode(' ', $css) . ' }'; - }; - - $durationAnimation = 0.005; - $durationToast = 5; - $css = $toCssFx('*', [ - 'animation-delay' => $durationAnimation . 's', - 'animation-duration' => $durationAnimation . 's', - 'transition-delay' => $durationAnimation . 's', - 'transition-duration' => $durationAnimation . 's', - ]) . $toCssFx('.ui.toast-container .toast-box .progressing.wait', [ - 'animation-duration' => $durationToast . 's', - 'transition-duration' => $durationToast . 's', - ]); - - $this->getSession()->executeScript( - 'if (Array.prototype.filter.call(document.getElementsByTagName("style"), e => e.getAttribute("about") === "atk-test-behat").length === 0) {' - . ' $(\'\').appendTo(\'head\');' - . ' }' - . 'jQuery.fx.off = true;' - ); - } - - protected function assertNoException(): void - { - foreach ($this->getSession()->getPage()->findAll('css', 'div.ui.negative.icon.message > div.content > div.header') as $elem) { - if ($elem->getText() === 'Critical Error') { - throw new Exception('Page contains uncaught exception'); - } - } - } - - protected function disableDebounce(): void - { - $this->getSession()->executeScript('atk.options.set("debounceTimeout", 20)'); - } - - /** - * Sleep for a certain time in ms. - * - * @Then I wait :arg1 ms - */ - public function iWait($arg1) - { - $this->getSession()->wait($arg1); - } - - /** - * @When I press button :arg1 - */ - public function iPressButton($arg1) - { - $button = $this->getSession()->getPage()->find('xpath', '//div[text()="' . $arg1 . '"]'); - // store button id. - $this->buttonId = $button->getAttribute('id'); - // fix "is out of bounds of viewport width and height" for Firefox - $button->focus(); - $button->click(); - } - - /** - * @Then I press menu button :arg1 using class :arg2 - */ - public function iPressMenuButtonUsingClass($arg1, $arg2) - { - $menu = $this->getSession()->getPage()->find('css', '.ui.menu.' . $arg2); - if (!$menu) { - throw new Exception('Unable to find a menu with class ' . $arg2); - } - - $link = $menu->find('xpath', '//a[text()="' . $arg1 . '"]'); - if (!$link) { - throw new Exception('Unable to find menu with title ' . $arg1); - } - - $this->getSession()->executeScript('$("#' . $link->getAttribute('id') . '").click()'); - } - - /** - * @Then I set calendar input name :arg1 with value :arg2 - */ - public function iSetCalendarInputNameWithValue($arg1, $arg2) - { - $script = '$(\'input[name="' . $arg1 . '"]\').get(0)._flatpickr.setDate("' . $arg2 . '")'; - $this->getSession()->executeScript($script); - } - - /** - * @Given I click link :arg1 - */ - public function iClickLink($arg1) - { - $link = $this->getSession()->getPage()->find('xpath', '//a[text()="' . $arg1 . '"]'); - $link->click(); - } - - /** - * @Then I click filter column name :arg1 - */ - public function iClickFilterColumnName($arg1) - { - $column = $this->getSession()->getPage()->find('css', "th[data-column='" . $arg1 . "']"); - if (!$column) { - throw new Exception('Unable to find a column ' . $arg1); - } - - $icon = $column->find('css', 'i'); - if (!$icon) { - throw new Exception('Column does not contain clickable icon.'); - } - - $this->getSession()->executeScript('$("#' . $icon->getAttribute('id') . '").click()'); - } - - /** - * @Given I click tab with title :arg1 - */ - public function iClickTabWithTitle($arg1) - { - $tabMenu = $this->getSession()->getPage()->find('css', '.ui.tabular.menu'); - if (!$tabMenu) { - throw new Exception('Unable to find a tab menu.'); - } - - $link = $tabMenu->find('xpath', '//a[text()="' . $arg1 . '"]'); - if (!$link) { - throw new Exception('Unable to find tab with title ' . $arg1); - } - - $this->getSession()->executeScript('$("#' . $link->getAttribute('id') . '").click()'); - } - - /** - * @Then I click first card on page - */ - public function iClickFirstCardOnPage() - { - $this->getSession()->executeScript('$(".atk-card")[0].click()'); - } - - /** - * @Then I click first element using class :arg1 - */ - public function iClickFirstElementUsingClass($arg1) - { - $this->getSession()->executeScript('$("' . $arg1 . '")[0].click()'); - } - - /** - * @Then I click paginator page :arg1 - */ - public function iClickPaginatorPage($arg1) - { - $this->getSession()->executeScript('$("a.item[data-page=' . $arg1 . ']").click()'); - } - - /** - * @Then I see button :arg1 - */ - public function iSee($arg1) - { - $element = $this->getSession()->getPage()->find('xpath', '//div[text()="' . $arg1 . '"]'); - if ($element->getAttribute('style')) { - throw new Exception("Element with text \"{$arg1}\" must be invisible"); - } - } - - /** - * @Then dump :arg1 - */ - public function dump($arg1) - { - $element = $this->getSession()->getPage()->find('xpath', '//div[text()="' . $arg1 . '"]'); - var_dump($element->getOuterHtml()); - } - - /** - * @Then I don't see button :arg1 - */ - public function iDontSee($arg1) - { - $element = $this->getSession()->getPage()->find('xpath', '//div[text()="' . $arg1 . '"]'); - if (mb_strpos('display: none', $element->getAttribute('style')) !== false) { - throw new Exception("Element with text \"{$arg1}\" must be invisible"); - } - } - - /** - * @Then Label changes to a number - */ - public function labelChangesToNumber() - { - $element = $this->getSession()->getPage()->findById($this->buttonId); - $value = trim($element->getHtml()); - if (!is_numeric($value)) { - throw new Exception('Label must be numeric on button: ' . $this->buttonId . ' : ' . $value); - } - } - - /** - * @Then /^container "([^"]*)" should display "([^"]*)" item\(s\)$/ - */ - public function containerShouldHaveNumberOfItem($selector, int $numberOfitems) - { - $items = $this->getSession()->getPage()->findAll('css', $selector); - $count = 0; - foreach ($items as $el => $item) { - ++$count; - } - if ($count !== $numberOfitems) { - throw new Exception('Items does not match. There were ' . $count . ' item in container'); - } - } - - /** - * @Then I press Modal button :arg - */ - public function iPressModalButton($arg) - { - $modal = $this->getSession()->getPage()->find('css', '.modal.transition.visible.active.front'); - if ($modal === null) { - throw new Exception('No modal found'); - } - // find button in modal - $btn = $modal->find('xpath', '//div[text()="' . $arg . '"]'); - if (!$btn) { - throw new Exception('Cannot find button in modal'); - } - $btn->click(); - } - - /** - * @Then Modal is open with text :arg1 - * - * Check if text is present in modal or dynamic modal. - */ - public function modalIsOpenWithText($arg1) - { - $modal = $this->waitForNodeElement('.modal.transition.visible.active.front'); - if ($modal === null) { - throw new Exception('No modal found'); - } - // find text in modal - $text = $modal->find('xpath', '//div[text()="' . $arg1 . '"]'); - if (!$text || trim($text->getText()) !== $arg1) { - throw new Exception('No such text in modal'); - } - } - - /** - * @Then Modal is showing text :arg1 inside tag :arg2 - */ - public function modalIsShowingText($arg1, $arg2) - { - // get modal - $modal = $this->waitForNodeElement('.modal.transition.visible.active.front'); - if ($modal === null) { - throw new Exception('No modal found'); - } - // find text in modal - $text = $modal->find('xpath', '//' . $arg2 . '[text()="' . $arg1 . '"]'); - if (!$text || $text->getText() !== $arg1) { - throw new Exception('No such text in modal'); - } - } - - /** - * Get a node element by it's selector. - * Will try to get element for 20ms. - * Exemple: Use with a modal window where reloaded content - * will resize it's window thus making it not accessible at first. - */ - private function waitForNodeElement(string $selector, int $ms = 20): ?NodeElement - { - $counter = 0; - $element = null; - while ($counter < $ms) { - $element = $this->getSession()->getPage()->find('css', $selector); - if ($element === null) { - usleep(1000); - ++$counter; - } else { - break; - } - } - - return $element; - } - - /** - * @Then Active tab should be :arg1 - */ - public function activeTabShouldBe($arg1) - { - $tab = $this->getSession()->getPage()->find('css', '.ui.tabular.menu > .item.active'); - if ($tab->getText() !== $arg1) { - throw new Exception('Active tab is not ' . $arg1); - } - } - - /** - * @Then I hide js modal - * - * Hide js modal. - */ - public function iHideJsModal() - { - $this->getSession()->executeScript('$(".modal.active.front").modal("hide")'); - } - - /** - * @Then I scroll to top - */ - public function iScrollToTop() - { - $this->getSession()->executeScript('window.scrollTo(0,0)'); - } - - /** - * @Then Toast display should contains text :arg1 - */ - public function toastDisplayShouldContainText($arg1) - { - // get toast - $toast = $this->getSession()->getPage()->find('css', '.ui.toast-container'); - if ($toast === null) { - throw new Exception('No toast found'); - } - $content = $toast->find('css', '.content'); - if ($content === null) { - throw new Exception('No Content in Toast'); - } - // find text in toast - $text = $content->find('xpath', '//div'); - if (!$text || mb_strpos($text->getText(), $arg1) === false) { - throw new Exception('No such text in toast'); - } - } - - /** - * @Then I select value :arg1 in lookup :arg2 - * - * Select a value in a lookup control. - */ - public function iSelectValueInLookup($arg1, $arg2) - { - // get dropdown item from semantic ui which is direct parent of input html element - $inputElem = $this->getSession()->getPage()->find('css', 'input[name=' . $arg2 . ']'); - if ($inputElem === null) { - throw new Exception('Lookup element not found: ' . $arg2); - } - $lookupElem = $inputElem->getParent(); - - // open dropdown and wait till fully opened (just a click is not triggering it) - $this->getSession()->executeScript('$("#' . $lookupElem->getAttribute('id') . '").dropdown("show")'); - $this->jqueryWait('$("#' . $lookupElem->getAttribute('id') . '").hasClass("visible")'); - - // select value - $valueElem = $lookupElem->find('xpath', '//div[text()="' . $arg1 . '"]'); - if ($valueElem === null || $valueElem->getText() !== $arg1) { - throw new Exception('Value not found: ' . $arg1); - } - $this->getSession()->executeScript('$("#' . $lookupElem->getAttribute('id') . '").dropdown("set selected", ' . $valueElem->getAttribute('data-value') . ');'); - $this->jqueryWait(); - - // hide dropdown and wait till fully closed - $this->getSession()->executeScript('$("#' . $lookupElem->getAttribute('id') . '").dropdown("hide");'); - $this->jqueryWait(); - // for unknown reasons, dropdown very often remains visible in CI, so hide twice - $this->getSession()->executeScript('$("#' . $lookupElem->getAttribute('id') . '").dropdown("hide");'); - $this->jqueryWait('!$("#' . $lookupElem->getAttribute('id') . '").hasClass("visible")'); - } - - /** - * @Then I search grid for :arg1 - */ - public function iSearchGridFor($arg1) - { - $search = $this->getSession()->getPage()->find('css', 'input.atk-grid-search'); - if (!$search) { - throw new Exception('Unable to find search input.'); - } - - $search->setValue($arg1); - } - - /** - * @Then /^page url should contains \'([^\']*)\'$/ - */ - public function pageUrlShouldContains($text) - { - $url = $this->getSession()->getCurrentUrl(); - if (!strpos($url, $text)) { - throw new Exception('Text : "' . $text . '" not found in ' . $url); - } - } - - /** - * @Then /^I wait for the page to be loaded$/ - */ - public function waitForThePageToBeLoaded() - { - // This line in test-unit.yml is causing test to fail. Need to increase wait time to compensate. - // sed -i 's/usleep(100000)/usleep(5000)/' vendor/behat/mink-selenium2-driver/src/Selenium2Driver.php - usleep(500000); - $this->getSession()->wait(10000, "document.readyState === 'complete'"); - } - - /** - * @Then I click icon using css :arg1 - */ - public function iClickIconUsingCss($arg1) - { - $icon = $this->getSession()->getPage()->find('css', $arg1); - if (!$icon) { - throw new Exception('Unable to find search remove icon.'); - } - - $icon->click(); - } - - /** - * Generic ScopeBuilder rule with select operator and input value. - * - * @Then /^rule "([^"]*)" operator is "([^"]*)" and value is "([^"]*)"$/ - */ - public function scopeBuilderRule($name, $operator, $value) - { - $rule = $this->assertScopeBuilderRuleExist($name); - $this->assertSelectedValue($rule, $operator, '.vqb-rule-operator select'); - $this->assertInputValue($rule, $value); - } - - /** - * hasOne reference or enum type rule for ScopeBuilder. - * - * @Then /^reference rule "([^"]*)" operator is "([^"]*)" and value is "([^"]*)"$/ - */ - public function scopeBuilderReferenceRule($name, $operator, $value) - { - $rule = $this->assertScopeBuilderRuleExist($name); - $this->assertSelectedValue($rule, $operator, '.vqb-rule-operator select'); - $this->assertDropdownValue($rule, $value, '.vqb-rule-input .active.item'); - } - - /** - * hasOne select or enum type rule for ScopeBuilder. - * - * @Then /^select rule "([^"]*)" operator is "([^"]*)" and value is "([^"]*)"$/ - */ - public function scopeBuilderSelectRule($name, $operator, $value) - { - $rule = $this->assertScopeBuilderRuleExist($name); - $this->assertSelectedValue($rule, $operator, '.vqb-rule-operator select'); - $this->assertSelectedValue($rule, $value, '.vqb-rule-input select'); - } - - /** - * Date, Time or Datetime rule for ScopeBuilder. - * - * @Then /^date rule "([^"]*)" operator is "([^"]*)" and value is "([^"]*)"$/ - */ - public function scopeBuilderDateRule($name, $operator, $value) - { - $rule = $this->assertScopeBuilderRuleExist($name); - $this->assertSelectedValue($rule, $operator, '.vqb-rule-operator select'); - $this->assertInputValue($rule, $value, 'input.form-control'); - } - - /** - * Boolean type rule for ScopeBuilder. - * - * @Then /^bool rule "([^"]*)" has value "([^"]*)"$/ - */ - public function scopeBuilderBoolRule($name, $value) - { - $this->assertScopeBuilderRuleExist($name); - $idx = ($value === 'Yes') ? 0 : 1; - $isChecked = $this->getSession()->evaluateScript('return $(\'[data-name="' . $name . '"]\').find(\'input\')[' . $idx . '].checked'); - if (!$isChecked) { - throw new Exception('Radio value selected is not: ' . $value); - } - } - - /** - * @Then /^I check if text in "([^"]*)" match text in "([^"]*)"/ - */ - public function compareElementText($compareSelector, $compareToSelector) - { - $compareContainer = $this->getSession()->getPage()->find('css', $compareSelector); - if (!$compareContainer) { - throw new Exception('Unable to find compare container: ' . $compareSelector); - } - - $expectedText = $compareContainer->getText(); - - $compareToContainer = $this->getSession()->getPage()->find('css', $compareToSelector); - if (!$compareToContainer) { - throw new Exception('Unable to find compare to container: ' . $compareToSelector); - } - - $compareToText = $compareToContainer->getText(); - - if ($expectedText !== $compareToText) { - throw new Exception('Data word does not match: ' . $compareToText . ' expected: ' . $expectedText); - } - } - - /** - * @Then /^I check if input value for "([^"]*)" match text in "([^"]*)"$/ - */ - public function compareInputValueToElementText($inputName, $selector) - { - $expected = $this->getSession()->getPage()->find('css', $selector)->getText(); - $input = $this->getSession()->getPage()->find('css', 'input[name="' . $inputName . '"]'); - if (!$input) { - throw new Exception('Unable to find input name: ' . $inputName); - } - - if (preg_replace('~\s*~', '', $expected) !== preg_replace('~\s*~', '', $input->getValue())) { - throw new Exception('Input value does not match: ' . $input->getValue() . ' expected: ' . $expected); - } - } - - /** - * @Then /^text in container using \'([^\']*)\' should contains \'([^\']*)\'$/ - */ - public function textInContainerUsingShouldContains($containerCss, $text) - { - $container = $this->getSession()->getPage()->find('css', $containerCss); - if (!$container) { - throw new Exception('Unable to find container: ' . $containerCss); - } - - if (trim($container->getText()) !== $text) { - throw new Exception('Text not in container ' . $text . ' - ' . $container->getText()); - } - } - - /** - * Find a dropdown component within an html element - * and check if value is set in dropdown. - */ - private function assertDropdownValue(NodeElement $element, string $value, string $selector) - { - $dropdown = $element->find('css', $selector); - if (!$dropdown) { - throw new Exception('Dropdown input not found using selector: ' . $selector); - } - - $dropdownValue = $dropdown->getHtml(); - if ($dropdownValue !== $value) { - throw new Exception('Value: "' . $value . '" not set using selector: ' . $selector); - } - } - - /** - * Find a select input type within an html element - * and check if value is selected. - */ - private function assertSelectedValue(NodeElement $element, string $value, string $selector) - { - $select = $element->find('css', $selector); - if (!$select) { - throw new Exception('Select input not found using selector: ' . $selector); - } - $selectValue = $select->getValue(); - if ($selectValue !== $value) { - throw new Exception('Value: "' . $value . '" not set using selector: ' . $selector); - } - } - - /** - * Find an input within an html element and check - * if value is set. - */ - private function assertInputValue(NodeElement $element, string $value, string $selector = 'input') - { - $input = $element->find('css', $selector); - if (!$input) { - throw new Exception('Input not found in selector: ' . $selector); - } - $inputValue = $input->getValue(); - if ($inputValue !== $value) { - throw new Exception('Input value not is not: ' . $value); - } - } - - private function assertScopeBuilderRuleExist(string $ruleName): NodeElement - { - $rule = $this->getSession()->getPage()->find('css', '.vqb-rule[data-name=' . $ruleName . ']'); - if (!$rule) { - throw new Exception('Rule not found: ' . $ruleName); - } - - return $rule; - } - - /** - * Wait for an element, usually an auto trigger element, to show that loading has start" - * Example, when entering value in JsSearch for grid. We need to auto trigger to fire before - * doing waiting for callback. - * $arg1 should represent the element selector for jQuery. - * - * @Then I wait for loading to start in :arg1 - */ - public function iWaitForLoadingToStartIn($arg1) - { - $this->getSession()->wait(2000, '$("' . $arg1 . '").hasClass("loading")'); - } - - protected function getFinishedScript(): string - { - return 'document.readyState === \'complete\'' - . ' && typeof jQuery !== \'undefined\' && jQuery.active === 0' - . ' && typeof atk !== \'undefined\' && atk.vueService.areComponentsLoaded()'; - } - - /** - * Wait till jQuery AJAX request finished and no animation is perform. - */ - protected function jqueryWait(string $extraWaitCondition = 'true', $maxWaitdurationMs = 5000) - { - $finishedScript = '(' . $this->getFinishedScript() . ') && (' . $extraWaitCondition . ')'; - - $s = microtime(true); - $c = 0; - while (microtime(true) - $s <= $maxWaitdurationMs / 1000) { - $this->getSession()->wait($maxWaitdurationMs, $finishedScript); - usleep(10000); - if ($this->getSession()->evaluateScript($finishedScript)) { - if (++$c >= 2) { - return; - } - } else { - $c = 0; - usleep(50000); - } - } - - throw new Exception('jQuery did not finished within a time limit'); - } - - /** - * @Then /^the field "([^"]*)" should start with "([^"]*)"$/ - */ - public function theShouldStartWith($arg1, $arg2) - { - $field = $this->assertSession()->fieldExists($arg1); - - if (mb_strpos($field->getValue(), $arg2) === false) { - throw new Exception('Field value ' . $field->getValue() . ' does not start with ' . $arg2); - } - } -} diff --git a/tests-behat/Bootstrap/ContextDump.php b/tests-behat/Bootstrap/ContextDump.php deleted file mode 100644 index c3a73122..00000000 --- a/tests-behat/Bootstrap/ContextDump.php +++ /dev/null @@ -1,38 +0,0 @@ -getTestResult()->getResultCode() === TestResult::FAILED) { - if ($this->getSession()->getDriver() instanceof \Behat\Mink\Driver\Selenium2Driver) { - echo 'Dump of failed step:' . "\n"; - echo 'Current page URL: ' . $this->getSession()->getCurrentUrl() . "\n"; - global $dumpPageCount; - if (++$dumpPageCount <= 1) { // prevent huge tests output - // upload screenshot here if needed in the future - // $screenshotData = $this->getSession()->getScreenshot(); - // echo 'Screenshot URL: ' . $screenshotUrl . "\n"; - echo 'Page source: ' . $this->getSession()->getPage()->getContent() . "\n"; - } else { - echo 'Page source: Source code is dumped for the first failed step only.' . "\n"; - } - } - } - } -} diff --git a/tests/Feature/PasswordManagementTest.php b/tests/Feature/PasswordManagementTest.php index 79b3dcbb..f0cb68be 100644 --- a/tests/Feature/PasswordManagementTest.php +++ b/tests/Feature/PasswordManagementTest.php @@ -24,48 +24,48 @@ public function testGenerateRandomPassword() public function testBasic() { $this->setupDefaultDb(); - $m = $this->getUserModel(); + $model = $this->getUserModel(); - $this->assertTrue($m->hasUserAction('generate_random_password')); - $this->assertTrue($m->hasUserAction('reset_password')); - $this->assertTrue($m->hasUserAction('check_password_strength')); + $this->assertTrue($model->hasUserAction('generate_random_password')); + $this->assertTrue($model->hasUserAction('reset_password')); + $this->assertTrue($model->hasUserAction('check_password_strength')); // simply generate password and return it - $this->assertIsString($m->executeUserAction('generate_random_password', 4)); + $this->assertIsString($model->executeUserAction('generate_random_password', 4)); // generate new password and set model record password field and save it and email if possible - $m->load(1); + $entity = $model->load(1); // replace callback so we can catch it - $m->getUserAction('sendEmail')->callback = function () { + $entity->getUserAction('sendEmail')->callback = function () { $args = func_get_args(); $this->assertInstanceOf(User::class, $args[0]); $this->assertStringContainsString('reset', $args[1]); $this->assertIsString($args[2]); }; - $this->assertIsString($pass = $m->executeUserAction('reset_password', 4)); - $this->assertTrue($m->getField('password')->verify($pass)); - $m->reload(); - $this->assertTrue($m->getField('password')->verify($pass)); + $this->assertIsString($pass = $entity->executeUserAction('reset_password', 4)); + $this->assertTrue($entity->getField('password')->verify($pass)); + $entity->reload(); + $this->assertTrue($entity->getField('password')->verify($pass)); // check password strength - $this->assertIsString($m->executeUserAction('check_password_strength', 'qwerty', ['strength' => 3])); // bad - $this->assertNull($m->executeUserAction('check_password_strength', 'Qwerty312#~%dsQWRDGFfdfh', ['strength' => 3])); // good + $this->assertIsString($entity->executeUserAction('check_password_strength', 'qwerty', ['strength' => 3])); // bad + $this->assertNull($entity->executeUserAction('check_password_strength', 'Qwerty312#~%dsQWRDGFfdfh', ['strength' => 3])); // good // check password length - $this->assertIsString($m->executeUserAction('check_password_strength', 'qwerty', ['len' => 8])); // bad - $this->assertNull($m->executeUserAction('check_password_strength', 'Qwerty312#~%dsQWRDGFfdfh', ['len' => 8])); // good + $this->assertIsString($entity->executeUserAction('check_password_strength', 'qwerty', ['len' => 8])); // bad + $this->assertNull($entity->executeUserAction('check_password_strength', 'Qwerty312#~%dsQWRDGFfdfh', ['len' => 8])); // good // check password symbols - $this->assertIsString($m->executeUserAction('check_password_strength', 'qwerty', ['symbols' => 4])); // bad - $this->assertNull($m->executeUserAction('check_password_strength', 'Qwerty312##$$%%^^@@fdsfs', ['symbols' => 4])); // good + $this->assertIsString($entity->executeUserAction('check_password_strength', 'qwerty', ['symbols' => 4])); // bad + $this->assertNull($entity->executeUserAction('check_password_strength', 'Qwerty312##$$%%^^@@fdsfs', ['symbols' => 4])); // good // check password numbers - $this->assertIsString($m->executeUserAction('check_password_strength', 'qwerty', ['numbers' => 4])); // bad - $this->assertNull($m->executeUserAction('check_password_strength', 'Qwerty312634dgf#@$', ['numbers' => 4])); // good + $this->assertIsString($entity->executeUserAction('check_password_strength', 'qwerty', ['numbers' => 4])); // bad + $this->assertNull($entity->executeUserAction('check_password_strength', 'Qwerty312634dgf#@$', ['numbers' => 4])); // good // check password upper letters - $this->assertIsString($m->executeUserAction('check_password_strength', 'qwerty', ['upper' => 4])); // bad - $this->assertNull($m->executeUserAction('check_password_strength', 'QwERTYqAZ324', ['upper' => 4])); // good + $this->assertIsString($entity->executeUserAction('check_password_strength', 'qwerty', ['upper' => 4])); // bad + $this->assertNull($entity->executeUserAction('check_password_strength', 'QwERTYqAZ324', ['upper' => 4])); // good } } diff --git a/tests/Feature/SendEmailActionTest.php b/tests/Feature/SendEmailActionTest.php index aa321643..96f321a1 100644 --- a/tests/Feature/SendEmailActionTest.php +++ b/tests/Feature/SendEmailActionTest.php @@ -16,17 +16,17 @@ public function testBasic() $this->assertTrue($m->hasUserAction('sendEmail')); - $m->load(1); + $entity = $m->load(1); // replace callback so we can catch it - $m->getUserAction('sendEmail')->callback = function () { + $entity->getUserAction('sendEmail')->callback = function () { $args = func_get_args(); $this->assertInstanceOf(User::class, $args[0]); $this->assertSame('Email subject', $args[1]); $this->assertSame('Email body', $args[2]); }; - $m->executeUserAction( + $entity->executeUserAction( 'sendEmail', 'Email subject', 'Email body' diff --git a/tests/Feature/UniqueFieldValueTest.php b/tests/Feature/UniqueFieldValueTest.php index 4debfb25..66b2f316 100644 --- a/tests/Feature/UniqueFieldValueTest.php +++ b/tests/Feature/UniqueFieldValueTest.php @@ -43,10 +43,13 @@ public function testBasic() $this->setupDefaultDb(); $m = $this->getTestModel(); - (clone $m)->save(['name' => 'Test2']); + $entity = $m->createEntity(); + $entity->save(['name' => 'Test2']); $this->assertSame(2, count($m->export())); $this->expectException(ValidationException::class); - (clone $m)->save(['name' => 'Test1']); + + $entity = $m->createEntity(); + $entity->save(['name' => 'Test1']); } } diff --git a/tests/PasswordFieldTest.php b/tests/PasswordFieldTest.php index 872427f5..0b7e5743 100644 --- a/tests/PasswordFieldTest.php +++ b/tests/PasswordFieldTest.php @@ -15,16 +15,17 @@ public function testPasswordField() $m = new Model(); $m->addField('p', [Password::class]); - $m->set('p', 'mypass'); + $entity = $m->createEntity(); + $entity->set('p', 'mypass'); // when setting password, you can retrieve it back while it's not yet saved - $this->assertSame('mypass', $m->get('p')); + $this->assertSame('mypass', $entity->get('p')); // password changed, so it's dirty. - $this->assertTrue($m->isDirty('p')); + $this->assertTrue($entity->isDirty('p')); - $this->assertFalse($m->compare('p', 'badpass')); - $this->assertTrue($m->compare('p', 'mypass')); + $this->assertFalse($entity->compare('p', 'badpass')); + $this->assertTrue($entity->compare('p', 'mypass')); } public function testPasswordPersistence() @@ -35,40 +36,40 @@ public function testPasswordPersistence() $m->addField('p', [Password::class]); // making sure cloning does not break things - $m = clone $m; + $entity = $m->createEntity(); // when setting password, you can retrieve it back while it's not yet saved - $m->set('p', 'mypass'); - $this->assertSame('mypass', $m->get('p')); - $m->save(); + $entity->set('p', 'mypass'); + $this->assertSame('mypass', $entity->get('p')); + $entity->save(); // stored encoded password - $enc = $this->getProtected($p, 'data')['data'][1]['p']; //->getRowById($m, 1)->getValue('p'); + $enc = $this->getProtected($p, 'data')['data']->getRowById($m, 1)->getValue('p'); $this->assertTrue(is_string($enc)); $this->assertNotSame('mypass', $enc); // should have reloaded also - $this->assertNull($m->get('p')); + $this->assertNull($entity->get('p')); // password value after load is null, but it still should validate/verify - $this->assertFalse($m->getField('p')->verify('badpass')); - $this->assertTrue($m->getField('p')->verify('mypass')); + $this->assertFalse($entity->getField('p')->verify('badpass')); + $this->assertTrue($entity->getField('p')->verify('mypass')); // password shouldn't be dirty here - $this->assertFalse($m->isDirty('p')); + $this->assertFalse($entity->isDirty('p')); - $m->set('p', 'newpass'); - $this->assertTrue($m->isDirty('p')); - $this->assertFalse($m->getField('p')->verify('mypass')); - $this->assertTrue($m->getField('p')->verify('newpass')); + $entity->set('p', 'newpass'); + $this->assertTrue($entity->isDirty('p')); + $this->assertFalse($entity->getField('p')->verify('mypass')); + $this->assertTrue($entity->getField('p')->verify('newpass')); - $m->save(); - $this->assertFalse($m->isDirty('p')); - $this->assertFalse($m->getField('p')->verify('mypass')); - $this->assertTrue($m->getField('p')->verify('newpass')); + $entity->save(); + $this->assertFalse($entity->isDirty('p')); + $this->assertFalse($entity->getField('p')->verify('mypass')); + $this->assertTrue($entity->getField('p')->verify('newpass')); // will have new hash - $this->assertNotSame($enc, $this->getProtected($p, 'data')['data'][1]['p']); //->getRowById($m, 1)->getValue('p')); + $this->assertNotSame($enc, $this->getProtected($p, 'data')['data']->getRowById($m, 1)->getValue('p')); } public function testCanNotCompareEmptyException()