diff --git a/.github/workflows/tests-nightly.yml b/.github/workflows/tests-nightly.yml index 15dce25..8f9b8d4 100644 --- a/.github/workflows/tests-nightly.yml +++ b/.github/workflows/tests-nightly.yml @@ -55,6 +55,16 @@ jobs: composer-script: 'test:integration:ci' wp-env-config-file: '.wp-env.tests.json' + multisite-integration: + name: Integration (multisite) + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c + with: + project-path: '.' + php-version: '8.5' + wp-env-core: 'https://wordpress.org/nightly-builds/wordpress-latest.zip' + composer-script: 'test:integration:multisite:ci' + wp-env-config-file: '.wp-env.multisite.json' + shared-e2e: name: E2E (shared environment) uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@b107001cce9f2960495d9d50481c8540d228f60c diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 927dcfa..61fd68a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -76,6 +76,17 @@ jobs: composer-script: 'test:integration:ci' wp-env-config-file: '.wp-env.tests.json' + multisite-integration: + name: Integration (multisite) + needs: plugins + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c + with: + project-path: '.' + php-version: '8.5' + wp-version: ${{ needs.plugins.outputs.newest-wordpress }} + composer-script: 'test:integration:multisite:ci' + wp-env-config-file: '.wp-env.multisite.json' + shared-e2e: name: E2E (shared environment) needs: plugins diff --git a/.wp-env.multisite.json b/.wp-env.multisite.json new file mode 100644 index 0000000..87c9d53 --- /dev/null +++ b/.wp-env.multisite.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://schemas.wp.org/trunk/wp-env.json", + "core": "WordPress/WordPress#tags/7.1.4", + "phpVersion": "8.5", + "port": 8821, + "testsEnvironment": false, + "multisite": true, + "mappings": { + "wp-content/plugins/linked-orders-for-woocommerce": "./plugins/linked-orders-for-woocommerce", + "wp-content/plugins/internal-comments": "./plugins/internal-comments", + "wp-content/plugins/locked-payment-methods-for-woocommerce": "./plugins/locked-payment-methods-for-woocommerce", + "wp-content/plugins/woocommerce": "./vendor/wp-plugin/woocommerce" + }, + "config": { + "WP_DEBUG": true, + "SCRIPT_DEBUG": true, + "WP_ENVIRONMENT_TYPE": false + }, + "lifecycleScripts": { + "afterStart": "node_modules/.bin/wp-env --config .wp-env.multisite.json run cli wp plugin activate woocommerce --network && node_modules/.bin/wp-env --config .wp-env.multisite.json run cli wp wc hpos enable --ignore-plugin-compatibility && node_modules/.bin/wp-env --config .wp-env.multisite.json run cli wp plugin activate linked-orders-for-woocommerce internal-comments locked-payment-methods-for-woocommerce --network" + } +} diff --git a/AGENTS.md b/AGENTS.md index 542a069..2433193 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,12 +14,13 @@ wordpress-plugins/ │ └── EndToEnd/ the shared environment's checks: every plugin is active and boots, and the plugins work side by side ├── .github/workflows/ │ ├── plugins.yml lists the plugins, their mirrors and readmes for the other workflows, checks a release tag names one, and lists each one's WordPress versions for Tests -│ ├── quality.yml, tests.yml, tests-nightly.yml, audit.yml the plugins' own lanes, once per plugin; Quality's script lint and the audit also over the root, and Tests and the nightly also over the shared environment +│ ├── quality.yml, tests.yml, tests-nightly.yml, audit.yml the plugins' own lanes, once per plugin; Quality's script lint and the audit also over the root, and Tests and the nightly also over the shared and multisite environments │ ├── codeql.yml, workflow-checks.yml the template's, workflow-checks.yml also linting each plugin's workflows │ ├── release-rehearsal.yml rehearses each plugin's release, from build to the built-artifact test, on pull requests and trunk, publishing nothing │ └── split-plugins.yml splits each plugin to the mirror its composer.json name gives, and forwards /vX.Y.Z as vX.Y.Z +├── .wp-env.multisite.json the multisite environment on port 8821: a network with every plugin and WooCommerce network-activated, as production ├── .wp-env.tests.json the shared environment on port 8820: every plugin and WooCommerce from vendor/, as production -├── composer.json WooCommerce for the shared environment, the shared Integration run and the plugins:* fan-outs +├── composer.json WooCommerce for the shared environments, their Integration runs and the plugins:* fan-outs └── package.json wp-env, Playwright and the shared spec's lint, and the plugins:* fan-outs ``` @@ -29,6 +30,7 @@ wordpress-plugins/ npm run setup # every install, then every plugin's build composer plugins:quality-check # each plugin's quality-check, in its own environments composer test:integration # starts the shared environment and runs each plugin's Integration suite in it +composer test:integration:multisite # starts the multisite environment and runs each plugin's multisite group in it npm run test:e2e # the shared environment's checks npm run plugins:test:e2e # each plugin's end-to-end suite, in its own environment npm run lint:all # the shared spec's checks, then every plugin's PHP, script, style and type checks @@ -39,7 +41,7 @@ actionlint .github/workflows/*.yml plugins/*/.github/workflows/*.yml && zizmor . ## Conventions 1. **Plugins.** A plugin starts from wordpress-plugin-template's trunk through the template's fill-in script, run in a clone of the template, and its directory keeps the template's tooling, docs and workflows but for `CONTRIBUTING.md`, the pull-request and issue templates, `.github/dependabot.yml` and the fill-in's workflow and script: its mirror is a public repository users land on, while contributions, issues and dependency updates belong here. Its `composer.json` name is its mirror, `ahegyes/wc-` for a WooCommerce plugin and `ahegyes/wp-` otherwise. -2. **Root lanes.** The root's workflows find the plugins through `plugins.yml`, so a plugin joins every lane without a workflow edit. The `plugins:*` fan-outs, the shared environment's mappings and activation, `test:integration:run`, the shared spec's boot row, the README table, the layout tree above and both issue forms name each plugin. +2. **Root lanes.** The root's workflows find the plugins through `plugins.yml`, so a plugin joins every lane without a workflow edit. The `plugins:*` fan-outs, the shared and multisite environments' mappings and activation, `test:integration:run`, `test:integration:multisite:run`, the shared spec's boot row, the README table, the layout tree above and both issue forms name each plugin. 3. **Ports.** wp-env ports are allocated in threes: the shared environment takes 8820 and the root's multisite environment 8821, the three plugins here take their development and tests pairs from 8822 to 8827 and their multisite ports from 8828 to 8830, and a new plugin takes the next free three, from 8831, for its development, tests and multisite sites. 4. **v1 successors.** A plugin released before v2 keeps its wordpress.org slug, text domain, main file name, capabilities, and every hook, function and constant another plugin can rely on, and starts at 2.0.0. It keeps its option and meta keys too, unless a major version migrates them in a way that preserves the data, with a changelog line and an Upgrade Notice line, as Locked Payment Methods' 2.0.0 does. Its v1 hook literals fire as deprecated beside their v2 names until the next major, and every break is listed in its changelog and its readme's Upgrade Notice. 5. **Releases.** A plugin releases from its mirror: the tag `/vX.Y.Z` here reaches the mirror as `vX.Y.Z`, never force-pushed, and the mirror holds the `wp-org-release` environment where the plugin is on wordpress.org. `CONTRIBUTING.md` holds the release step. diff --git a/README.md b/README.md index d270faf..aa86d3c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ composer test:integration npm run test:e2e ``` -`composer test:integration` runs each plugin's Integration suite in the shared environment, and `npm run test:e2e` its checks of the plugins side by side. +`composer test:integration` runs each plugin's Integration suite in the shared environment, `composer test:integration:multisite` each plugin's `multisite` group on a network on , and `npm run test:e2e` the shared environment's checks of the plugins side by side. ## Releases diff --git a/SECURITY.md b/SECURITY.md index d2af903..659ca06 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,10 @@ Report it privately through [GitHub's security advisory form](https://github.com/ahegyes/wordpress-plugins/security/advisories/new), never in a public issue. The first response comes within 7 days. +## Supported versions + +For each plugin, only the latest 2.x release receives security fixes. 1.x receives none. + ## Scope In scope: every plugin under `plugins/`, its prefixing configuration and its workflows, and the monorepo's own workflows. diff --git a/composer.json b/composer.json index e56d8e1..a178fd3 100644 --- a/composer.json +++ b/composer.json @@ -46,9 +46,9 @@ "Composer\\Config::disableProcessTimeout", "@putenv CI=1", "npm --prefix=. run plugins:build", - "node_modules/.bin/wp-env --config .wp-env.tests.json run cli --env-cwd=wp-content/plugins/linked-orders-for-woocommerce vendor/bin/phpunit --testsuite=Integration", - "node_modules/.bin/wp-env --config .wp-env.tests.json run cli --env-cwd=wp-content/plugins/internal-comments vendor/bin/phpunit --testsuite=Integration", - "node_modules/.bin/wp-env --config .wp-env.tests.json run cli --env-cwd=wp-content/plugins/locked-payment-methods-for-woocommerce vendor/bin/phpunit --testsuite=Integration --exclude-group groups" + "node_modules/.bin/wp-env --config .wp-env.tests.json run cli --env-cwd=wp-content/plugins/linked-orders-for-woocommerce vendor/bin/phpunit --testsuite=Integration --exclude-group=multisite", + "node_modules/.bin/wp-env --config .wp-env.tests.json run cli --env-cwd=wp-content/plugins/internal-comments vendor/bin/phpunit --testsuite=Integration --exclude-group=multisite", + "node_modules/.bin/wp-env --config .wp-env.tests.json run cli --env-cwd=wp-content/plugins/locked-payment-methods-for-woocommerce vendor/bin/phpunit --testsuite=Integration --exclude-group=groups --exclude-group=multisite" ], "test:integration:ci": [ "Composer\\Config::disableProcessTimeout", @@ -56,6 +56,26 @@ "npm --prefix=. run plugins:packages-install:ci", "@test:integration:run" ], + "test:integration:multisite": [ + "Composer\\Config::disableProcessTimeout", + "@putenv CI=1", + "npm --prefix=. run wp-env:multisite:start", + "@test:integration:multisite:run" + ], + "test:integration:multisite:run": [ + "Composer\\Config::disableProcessTimeout", + "@putenv CI=1", + "npm --prefix=. run plugins:build", + "node_modules/.bin/wp-env --config .wp-env.multisite.json run cli --env-cwd=wp-content/plugins/linked-orders-for-woocommerce vendor/bin/phpunit --testsuite=Integration --group=multisite", + "node_modules/.bin/wp-env --config .wp-env.multisite.json run cli --env-cwd=wp-content/plugins/internal-comments vendor/bin/phpunit --testsuite=Integration --group=multisite", + "node_modules/.bin/wp-env --config .wp-env.multisite.json run cli --env-cwd=wp-content/plugins/locked-payment-methods-for-woocommerce vendor/bin/phpunit --testsuite=Integration --group=multisite" + ], + "test:integration:multisite:ci": [ + "Composer\\Config::disableProcessTimeout", + "@plugins:packages-install", + "npm --prefix=. run plugins:packages-install:ci", + "@test:integration:multisite:run" + ], "quality-check": [ "@composer validate --strict", diff --git a/package.json b/package.json index 1634993..ed4bb6c 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,11 @@ "wp-env:tests:update": "wp-env --config .wp-env.tests.json start --update", "wp-env:tests:destroy": "wp-env --config .wp-env.tests.json destroy", + "wp-env:multisite:start": "wp-env --config .wp-env.multisite.json start", + "wp-env:multisite:stop": "wp-env --config .wp-env.multisite.json stop", + "wp-env:multisite:update": "wp-env --config .wp-env.multisite.json start --update", + "wp-env:multisite:destroy": "wp-env --config .wp-env.multisite.json destroy", + "audit": "composer audit --locked --abandoned=report && npm audit --omit=dev --audit-level=high", "audit:fix": "npm audit fix", "check:engines": "wp-scripts check-engines", diff --git a/plugins/internal-comments/.distignore b/plugins/internal-comments/.distignore index 5faf091..8aa76ac 100644 --- a/plugins/internal-comments/.distignore +++ b/plugins/internal-comments/.distignore @@ -1,40 +1,52 @@ -.cache -.distignore -.editorconfig +# Baseline wp dist-archive exclusions for WordPress projects. + .git .github -.gitignore .idea .vscode -.wp-env.json -.wp-env.override.json -.wp-env.tests.json - +.editorconfig +.gitattributes +.gitignore +.distignore AGENTS.md -CHANGELOG.md CLAUDE.md CONTRIBUTING.md -README.md SECURITY.md - -changelog/ -docs/ - -node_modules/ -tests/ -vendor/bin/ - +/changelog +node_modules +tests +vendor/bin composer.json composer.lock package.json package-lock.json +.phpcs*.xml* +phpcs*.xml* +.phpstan*.neon* +phpstan*.neon* +phpunit*.xml* +.phpunit.result.cache +.phpunit.cache +infection.json* +eslint.config.* +stylelint.config.* tsconfig.json -eslint.config.mjs -stylelint.config.js -phpcs.dist.xml -phpcs.tests.dist.xml -phpstan.dist.neon -phpstan.safeguards.neon -phpunit.dist.xml -playwright.config.js +playwright.config.* +postcss.config.* scoper.inc.php +.wp-env* +.wordpress-org + +# Secret material, with .pem anchored to the root so certificates bundled under vendor/ still ship. +.env* +/*.pem +*.key +id_rsa* +.npmrc +auth.json + +# The plugin's own exclusions, beyond configs' baseline above. +.cache +CHANGELOG.md +README.md +docs/ diff --git a/plugins/internal-comments/.github/workflows/audit.yml b/plugins/internal-comments/.github/workflows/audit.yml index ff6c180..345f398 100644 --- a/plugins/internal-comments/.github/workflows/audit.yml +++ b/plugins/internal-comments/.github/workflows/audit.yml @@ -5,17 +5,18 @@ on: branches: [trunk] pull_request: schedule: - # Weekly Monday at 07:00 UTC — staggered after dependabot (06:00) so PRs from - # dependency bumps surface audit results in the same morning batch. + # Weekly, so advisories published against an unchanged lock surface without a push. - cron: '0 7 * * 1' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read jobs: audit: name: Audit - # npm gate audits production deps only — the dev-tooling graph (wp-scripts/playwright trees) is advisory via dependabot alerts. + # Only production npm packages gate the audit, since a release ships none of the development tooling. uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@b107001cce9f2960495d9d50481c8540d228f60c - with: - npm-audit-flags: '--omit=dev --audit-level=high' diff --git a/plugins/internal-comments/.github/workflows/codeql.yml b/plugins/internal-comments/.github/workflows/codeql.yml index 1e142c0..cd74e65 100644 --- a/plugins/internal-comments/.github/workflows/codeql.yml +++ b/plugins/internal-comments/.github/workflows/codeql.yml @@ -16,8 +16,11 @@ permissions: {} jobs: analyze: + name: CodeQL permissions: actions: read contents: read security-events: write uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@b107001cce9f2960495d9d50481c8540d228f60c + with: + languages: '["actions", "javascript-typescript"]' diff --git a/plugins/internal-comments/.github/workflows/quality.yml b/plugins/internal-comments/.github/workflows/quality.yml index 0fe5aa1..6263e15 100644 --- a/plugins/internal-comments/.github/workflows/quality.yml +++ b/plugins/internal-comments/.github/workflows/quality.yml @@ -5,19 +5,23 @@ on: branches: [trunk] pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read jobs: lint-php: - name: PHP Lint + name: Lint PHP uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@b107001cce9f2960495d9d50481c8540d228f60c with: php-version: '8.5' - scripts: '["lint:php:phpcs", "lint:php:phpcs:tests", "lint:php:phpstan"]' + scripts: '["lint:php:phpcs", "lint:php:phpcs:tests", "lint:php:phpstan", "changelog:validate"]' lint-scripts-styles: - name: Scripts and styles lint + name: Lint scripts and styles uses: ahegyes/wordpress-configs/.github/workflows/reusable-scripts-styles-lint.yml@b107001cce9f2960495d9d50481c8540d228f60c with: scripts: '["lint:scripts", "lint:styles", "lint:types"]' @@ -36,12 +40,12 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # GitHub refuses a GITHUB_TOKEN push that changes a workflow, so the fill-in leaves workflows alone and they read the slug the plugin declares. + # The workflows read the slug the plugin declares, as the template's do, so a change to the template's applies here unedited. - name: Read the slug from composer.json id: slug run: | slug=$(jq -r '.extra["text-domain"] // empty' composer.json) - if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + if ! [[ "$slug" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]]; then echo "::error::composer.json's extra.text-domain must be the plugin's kebab-case slug — got '$slug'." exit 1 fi @@ -58,6 +62,7 @@ jobs: readme-lint: name: readme.txt lint runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -66,18 +71,3 @@ jobs: with: path: readme.txt fail-on: error - - changelog: - name: Changelog fragments - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 - with: - php-version: '8.5' - coverage: none - tools: composer:v2 - - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 - - run: composer changelog:validate diff --git a/plugins/internal-comments/.github/workflows/release.yml b/plugins/internal-comments/.github/workflows/release.yml index 5e2dc60..d6b730a 100644 --- a/plugins/internal-comments/.github/workflows/release.yml +++ b/plugins/internal-comments/.github/workflows/release.yml @@ -24,12 +24,12 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # GitHub refuses a GITHUB_TOKEN push that changes a workflow, so the fill-in leaves workflows alone and they read the slug the plugin declares. + # The workflows read the slug the plugin declares, as the template's do, so a change to the template's applies here unedited. - name: Read the slug from composer.json id: slug run: | slug=$(jq -r '.extra["text-domain"] // empty' composer.json) - if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + if ! [[ "$slug" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]]; then echo "::error::composer.json's extra.text-domain must be the plugin's kebab-case slug — got '$slug'." exit 1 fi diff --git a/plugins/internal-comments/.github/workflows/tests-nightly.yml b/plugins/internal-comments/.github/workflows/tests-nightly.yml index eec84c9..69824e8 100644 --- a/plugins/internal-comments/.github/workflows/tests-nightly.yml +++ b/plugins/internal-comments/.github/workflows/tests-nightly.yml @@ -19,6 +19,15 @@ jobs: composer-script: 'test:integration:run' wp-env-config-file: '.wp-env.tests.json' + integration-multisite: + name: Integration (multisite) + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c + with: + php-version: '8.5' + wp-env-core: 'https://wordpress.org/nightly-builds/wordpress-latest.zip' + composer-script: 'test:integration:multisite:run' + wp-env-config-file: '.wp-env.multisite.json' + e2e: name: E2E uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@b107001cce9f2960495d9d50481c8540d228f60c diff --git a/plugins/internal-comments/.github/workflows/tests.yml b/plugins/internal-comments/.github/workflows/tests.yml index fb6ef15..466d0d7 100644 --- a/plugins/internal-comments/.github/workflows/tests.yml +++ b/plugins/internal-comments/.github/workflows/tests.yml @@ -6,6 +6,10 @@ on: pull_request: workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -22,8 +26,6 @@ jobs: name: WordPress versions runs-on: ubuntu-latest timeout-minutes: 5 - permissions: - contents: read outputs: versions: ${{ steps.versions.outputs.versions }} steps: @@ -41,7 +43,7 @@ jobs: echo "::error::$entry declares no Requires at least header." exit 1 fi - offers=$(curl -fsS https://api.wordpress.org/core/version-check/1.7/) + offers=$(curl -fsS --retry 3 --retry-all-errors --max-time 20 https://api.wordpress.org/core/version-check/1.7/) versions=$(printf '%s' "$offers" | jq -c --arg floor "$floor" '[.offers[].version | select((split(".")[0:2] | map(tonumber)) >= ($floor | split(".")[0:2] | map(tonumber)))] | unique | if length == 0 then error("No WordPress release at or above \($floor).") else . end') echo "versions=$versions" >> "$GITHUB_OUTPUT" @@ -59,6 +61,20 @@ jobs: composer-script: 'test:integration:run' wp-env-config-file: '.wp-env.tests.json' + integration-multisite: + name: Integration (multisite) + needs: wordpress + strategy: + fail-fast: false + matrix: + wp: ${{ fromJSON(needs.wordpress.outputs.versions) }} + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c + with: + php-version: '8.5' + wp-version: ${{ matrix.wp }} + composer-script: 'test:integration:multisite:run' + wp-env-config-file: '.wp-env.multisite.json' + e2e: name: E2E needs: wordpress diff --git a/plugins/internal-comments/.github/workflows/workflow-checks.yml b/plugins/internal-comments/.github/workflows/workflow-checks.yml index bafdc75..e954a68 100644 --- a/plugins/internal-comments/.github/workflows/workflow-checks.yml +++ b/plugins/internal-comments/.github/workflows/workflow-checks.yml @@ -3,12 +3,11 @@ name: Workflow Checks on: push: branches: [trunk] - paths: ['.github/workflows/**'] + paths: ['.github/**'] pull_request: - paths: ['.github/workflows/**'] + paths: ['.github/**'] schedule: - # Weekly Monday 06:00 UTC. Keeps zizmor's online audits (e.g. - # known-vulnerable-actions) fresh between workflow changes. + # Re-runs zizmor's online audits between workflow changes. - cron: '0 6 * * 1' workflow_dispatch: @@ -20,6 +19,7 @@ permissions: {} jobs: checks: + name: Workflow Checks permissions: contents: read security-events: write diff --git a/plugins/internal-comments/.gitignore b/plugins/internal-comments/.gitignore index 216741b..df3cc85 100644 --- a/plugins/internal-comments/.gitignore +++ b/plugins/internal-comments/.gitignore @@ -1,7 +1,15 @@ vendor-prefixed assets/build tests/.cache -.wp-env.override.json +.wp-env*.override.json + +# Secret material is never tracked, so these match at any depth as a backstop against an accidental git add. +.env* +*.pem +*.key +id_rsa* +.npmrc +auth.json # Created by https://www.toptal.com/developers/gitignore/api/node,linux,macos,phpunit,windows,composer,phpstorm+all,visualstudiocode # Edit at https://www.toptal.com/developers/gitignore?templates=node,linux,macos,phpunit,windows,composer,phpstorm+all,visualstudiocode diff --git a/plugins/internal-comments/.wp-env.multisite.json b/plugins/internal-comments/.wp-env.multisite.json new file mode 100644 index 0000000..f188c57 --- /dev/null +++ b/plugins/internal-comments/.wp-env.multisite.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://schemas.wp.org/trunk/wp-env.json", + "core": "WordPress/WordPress#tags/7.1.4", + "phpVersion": "8.5", + "port": 8829, + "testsEnvironment": false, + "multisite": true, + "mappings": { + "wp-content/plugins/internal-comments": ".", + "wp-content/plugins/woocommerce": "./vendor/wp-plugin/woocommerce" + }, + "config": { + "WP_DEBUG": true, + "SCRIPT_DEBUG": true, + "WP_ENVIRONMENT_TYPE": false + }, + "lifecycleScripts": { + "afterStart": "node_modules/.bin/wp-env --config .wp-env.multisite.json run cli wp plugin activate woocommerce --network && node_modules/.bin/wp-env --config .wp-env.multisite.json run cli wp wc hpos enable --ignore-plugin-compatibility && node_modules/.bin/wp-env --config .wp-env.multisite.json run cli wp plugin activate internal-comments --network" + } +} diff --git a/plugins/internal-comments/AGENTS.md b/plugins/internal-comments/AGENTS.md index 108db38..673f0ae 100644 --- a/plugins/internal-comments/AGENTS.md +++ b/plugins/internal-comments/AGENTS.md @@ -41,6 +41,7 @@ changelog/ the pending entries changelog:write turns into CHAN composer packages-install # dependencies, then php-scoper into vendor-prefixed/ composer test:unit composer test:integration # starts .wp-env.tests.json, then runs test:integration:run, which builds the scripts first +composer test:integration:multisite # starts .wp-env.multisite.json, then runs the Integration suite's multisite group, which the default run skips composer quality-check # manifest and changelog validation, PHPCS (both profiles), PHPStan (both profiles), Unit and Integration composer changelog:add # stages an entry in changelog/, which the release step in the monorepo's CONTRIBUTING.md writes npm run setup # both installs, the scripts' build, then the development site @@ -55,8 +56,9 @@ Run wp-scripts only through the npm scripts or `node_modules/.bin/wp-scripts`: ` - `.wp-env.json`: development, as `local`, on port 8824, behind `npm run setup` and `wp-env:*`. The framework rethrows boot and migration failures there. - `.wp-env.tests.json`: Integration and end to end, as `production`, on port 8825, behind `wp-env:tests:*`, `composer test:integration` and `npm run test:e2e`. The end-to-end suite deactivates and reactivates WooCommerce there. +- `.wp-env.multisite.json`: a network with WooCommerce and the plugin network-activated, as `production`, on port 8829, behind `wp-env:multisite:*` and `composer test:integration:multisite`, for the Integration suite's `multisite` group. -Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI runs Integration and end to end on every WordPress branch from the entry's `Requires at least` up, and nightly on WordPress trunk. +Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI runs Integration and end to end on every WordPress branch from the entry's `Requires at least` up, and nightly on WordPress trunk. ## Conventions @@ -67,11 +69,15 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run 5. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input. A REST route declares each parameter's type and sanitizer in its `args`. 6. **Options and data.** A setting is read through v1's API in `includes/settings.php`, whose validation is each setting's one typed read and whose reads apply v1's filters, safeguards excepted. v1's settings keep their `dws-internal-comments_` rows and its comments their `dws_internal` type and `dws_ic_is_private` and `_dws_ic_` meta; anything new lives under `dws_ic_`, or `_dws_ic_` for hidden meta. The footprint covers those prefixes and v1's other rows by name. 7. **Internal status.** An internal comment is stored under the `dws_internal` status, which keeps it out of WordPress's queries, counts, feeds and REST API without the plugin's help, so no code stores one under another status, and a query that lists them names the status. `Comments` puts back any that WordPress or another plugin moves, announcing the move as a status change after WordPress announces its own, so caches that follow status changes, such as WooCommerce's comment counts, end where the comment is; the migration's bulk move resets WooCommerce's counts instead. Until the migration has moved the comments v1 stored approved, it leaves the type out of every comment query and feed that names neither it nor the status. Every check of what a user may do with one goes through the `see_`, `edit_` and `trash_dws_internal_comment` meta capabilities. -8. **Order storage.** Order code takes ids WooCommerce resolves in either storage, through `dws_ic_get_post_type()` and `edit_post`, which WooCommerce maps for orders, and WooCommerce's screen ids and list hooks, never the posts table; every Integration row whose outcome the order storage can change runs on all four storages through `OrderStorage`. +8. **Order storage.** Order code takes ids WooCommerce resolves in either storage, through `dws_ic_get_post_type()` and `edit_post`, which WooCommerce maps for orders, and WooCommerce's screen ids and list hooks, never the posts table; every Integration row whose outcome the order storage can change runs on all four storages through `OrderStorageTrait`. 9. **v1 hooks.** A hook v1 fired keeps its v1 literal, fired first through `apply_filters_deprecated()` or `do_action_deprecated()` with `'2.0.0'` and the v2 name, then the v2 name. 10. **Safeguards** live in `includes/safeguards.php`, call only WordPress and that file, and relax only through their own filter, through `dws_ic_is_relaxed()` (the template's recipes). The entry loads them before the floor check, so they parse on PHP 7.4 and call only WordPress APIs present at the oldest WordPress the bootstrap package runs on, or guard them, which `phpstan.safeguards.neon` checks. 11. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails; bulk work is a batched step, which runs only from the runner's scheduled hook. +## Contract + +The plugin's contract is the elements tagged `@api`, the hooks it fires, and its option, meta and capability names and REST routes; breaking it ships as the next major. Every other function, constant and class, `src/` included, is internal and changes in any release. + ## Style - Every PHP file opens with `boot(); + $plugin = dws_ic_plugin(); } catch ( Throwable $error ) { dws_ic_handle_build_error( $error ); + return; } + + $plugin->boot(); // PluginBoot reports a failed composition itself, and a hook listener's throw is not a build error. } /** @@ -75,14 +80,19 @@ function dws_ic_boot(): void { */ function dws_ic_deactivate(): void { try { - dws_ic_plugin()->deactivate(); + $plugin = dws_ic_plugin(); } catch ( Throwable $error ) { dws_ic_handle_build_error( $error ); + return; } + + $plugin->deactivate(); } /** - * Declares that the plugin works with WooCommerce's order tables, where its comments on orders live as on orders stored as posts. + * Declares that the plugin works with WooCommerce's order tables. + * + * Its comments on orders live as on orders stored as posts, which its tests cover on both order storages. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/internal-comments/includes/assets.php b/plugins/internal-comments/includes/assets.php index f34bd0d..3ab48b6 100644 --- a/plugins/internal-comments/includes/assets.php +++ b/plugins/internal-comments/includes/assets.php @@ -13,7 +13,7 @@ defined( 'ABSPATH' ) || exit; /** - * Returns the payload wp-scripts generates beside a built file, which lists its dependencies, script module imports included, and its version. + * Returns the payload wp-scripts generates beside a built file. * * @since 2.0.0 * @version 2.0.0 @@ -28,8 +28,7 @@ function dws_ic_get_asset( string $path ): array { * * @var array{dependencies: list, version: string, type?: string, handle?: string} $asset */ - $asset = require plugin_dir_path( DWS_IC_FILE ) . dirname( $path ) . '/' . basename( $path, '.' . pathinfo( $path, PATHINFO_EXTENSION ) ) . '.asset.php'; - + $asset = require plugin_dir_path( DWS_IC_FILE ) . dirname( $path ) . '/' . pathinfo( $path, PATHINFO_FILENAME ) . '.asset.php'; return $asset; } @@ -39,9 +38,11 @@ function dws_ic_get_asset( string $path ): array { * @since 2.0.0 * @version 2.0.0 * - * @param non-empty-string $handle The script's handle. - * @param non-empty-string $path The built script's path, relative to the plugin's directory. - * @param array{strategy?: 'async'|'defer', in_footer?: bool, fetchpriority?: 'auto'|'low'|'high'} $args Optional. How the script loads. Default none, which loads it in the head. + * @phpstan-param array{strategy?: 'async'|'defer', in_footer?: bool, fetchpriority?: 'auto'|'low'|'high'} $args + * + * @param non-empty-string $handle The script's handle. + * @param non-empty-string $path The built script's path, relative to the plugin's directory. + * @param array $args Optional. How the script loads. Default none, which loads it in the head. * * @return bool */ @@ -63,12 +64,11 @@ function dws_ic_register_script( string $handle, string $path, array $args = arr * * @param non-empty-string $handle The stylesheet's handle. * @param non-empty-string $path The built stylesheet's path, relative to the plugin's directory. - * @param string $media Optional. The media the stylesheet applies to. Default 'all'. * * @return bool */ -function dws_ic_register_style( string $handle, string $path, string $media = 'all' ): bool { - $registered = wp_register_style( $handle, plugin_dir_url( DWS_IC_FILE ) . $path, array(), dws_ic_get_asset( $path )['version'], $media ); +function dws_ic_register_style( string $handle, string $path ): bool { + $registered = wp_register_style( $handle, plugin_dir_url( DWS_IC_FILE ) . $path, array(), dws_ic_get_asset( $path )['version'] ); if ( $registered ) { wp_style_add_data( $handle, 'rtl', 'replace' ); } diff --git a/plugins/internal-comments/includes/comments.php b/plugins/internal-comments/includes/comments.php index fa4087a..cc9b3b9 100644 --- a/plugins/internal-comments/includes/comments.php +++ b/plugins/internal-comments/includes/comments.php @@ -45,6 +45,8 @@ /** * Returns the comment that an id, a comment or an array holding a comment_ID names, or null when there is none. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -59,13 +61,14 @@ function dws_ic_get_comment( $comment ): ?WP_Comment { // get_comment() reads an empty id as the comment of the current loop. $comment = $comment instanceof WP_Comment || ( is_numeric( $comment ) && (int) $comment > 0 ) ? get_comment( $comment instanceof WP_Comment ? $comment : (int) $comment ) : null; - return $comment instanceof WP_Comment ? $comment : null; } /** * Returns whether a comment is internal, or null when there is no such comment. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -75,13 +78,14 @@ function dws_ic_get_comment( $comment ): ?WP_Comment { */ function dws_ic_is_internal_comment( $comment ): ?bool { $comment = dws_ic_get_comment( $comment ); - - return null === $comment ? null : DWS_IC_COMMENT_TYPE === $comment->comment_type; + return is_null( $comment ) ? null : DWS_IC_COMMENT_TYPE === $comment->comment_type; } /** * Returns whether a post's type, or an order's type in any of WooCommerce's storages, is one the settings select for internal comments. * + * @api + * * @since 1.1.0 * @version 2.0.0 * @@ -118,6 +122,8 @@ function dws_ic_get_post_type( ?int $post_id = null ): ?string { /** * Stores an internal comment by an author, the current user by default, and returns its id, or null when it could not be stored. * + * @api + * * @since 1.1.0 * @version 2.0.0 * @@ -164,13 +170,14 @@ function dws_ic_create_internal_comment( array $args = array(), ?WP_User $author $data = apply_filters( 'dws_ic/internal_comment/creation_data', $data ); $comment_id = wp_insert_comment( wp_slash( $data ) ); // @phpstan-ignore argument.type (The fields pass through public filters, which wp_insert_comment() accepts in any shape.) - return false === $comment_id ? null : $comment_id; } /** * Returns a post's internal comments the current user can see, threaded by default, each wrapped in the plugin's model. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -204,6 +211,8 @@ function dws_ic_get_internal_comments( int $post_id, array $args = array(), bool /** * Returns the internal comments a trashed post held under a status, as WordPress recorded them when it trashed the post, which the current user can see. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -236,6 +245,8 @@ function dws_ic_get_internal_comments_from_trash_meta( int $post_id, string $sta /** * Returns whether an internal comment is private, because its author or the author of one of its ancestors marked it so, or null for a comment that is not internal. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -245,8 +256,7 @@ function dws_ic_get_internal_comments_from_trash_meta( int $post_id, string $sta */ function dws_ic_is_private_comment( $comment ): ?bool { $owners = dws_ic_get_private_comment_owners( $comment ); - - return null === $owners ? null : array() !== $owners; + return is_null( $owners ) ? null : array() !== $owners; } /** @@ -261,15 +271,18 @@ function dws_ic_is_private_comment( $comment ): ?bool { */ function dws_ic_get_private_comment_owners( $comment ): ?array { $comment = dws_ic_get_comment( $comment ); - if ( null === $comment || DWS_IC_COMMENT_TYPE !== $comment->comment_type ) { + if ( is_null( $comment ) || DWS_IC_COMMENT_TYPE !== $comment->comment_type ) { return null; } $owners = array(); - for ( $node = $comment; null !== $node; $node = dws_ic_get_comment( $node->comment_parent ) ) { + $node = $comment; + while ( ! is_null( $node ) ) { if ( true === filter_var( get_comment_meta( (int) $node->comment_ID, DWS_IC_META_PRIVATE, true ), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ) { $owners[] = (int) $node->user_id; } + + $node = dws_ic_get_comment( $node->comment_parent ); } return $owners; @@ -294,7 +307,6 @@ function dws_ic_exclude_internal_type( $excluded ): array { } $types[] = DWS_IC_COMMENT_TYPE; - return $types; } @@ -331,6 +343,5 @@ function dws_ic_has_replies( int $comment_id ): bool { 'count' => true, ) ); - return is_int( $replies ) && 0 < $replies; } diff --git a/plugins/internal-comments/includes/deprecated.php b/plugins/internal-comments/includes/deprecated.php index 76c4c4f..53a7f70 100644 --- a/plugins/internal-comments/includes/deprecated.php +++ b/plugins/internal-comments/includes/deprecated.php @@ -31,6 +31,8 @@ /** * The plugin's untranslated name. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_ic_get_plugin_name(). @@ -40,6 +42,8 @@ /** * The plugin's version. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_ic_get_plugin_version(). @@ -49,6 +53,8 @@ /** * The path of the plugin's main file. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use DWS_IC_FILE. @@ -58,6 +64,8 @@ /** * The oldest PHP version the plugin runs on. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -67,6 +75,8 @@ /** * The oldest WordPress version the plugin runs on. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -105,6 +115,8 @@ /** * Returns the plugin's name, translated once init has fired. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_ic_get_plugin_name(). @@ -113,13 +125,14 @@ */ function dws_ic_name(): string { _deprecated_function( __FUNCTION__, '2.0.0', 'dws_ic_get_plugin_name()' ); - return dws_ic_get_plugin_name(); } /** * Returns the plugin's version. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_ic_get_plugin_version(). @@ -128,13 +141,14 @@ function dws_ic_name(): string { */ function dws_ic_version(): string { _deprecated_function( __FUNCTION__, '2.0.0', 'dws_ic_get_plugin_version()' ); - return dws_ic_get_plugin_version(); } /** * Returns the path of the plugin's main file. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -143,13 +157,14 @@ function dws_ic_version(): string { */ function dws_ic_path(): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return DWS_IC_PATH; } /** * Returns the oldest PHP version the plugin runs on. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -158,13 +173,14 @@ function dws_ic_path(): string { */ function dws_ic_min_php(): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return DWS_IC_MIN_PHP; } /** * Returns the oldest WordPress version the plugin runs on. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -173,13 +189,14 @@ function dws_ic_min_php(): string { */ function dws_ic_min_wp(): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return DWS_IC_MIN_WP; } /** * Returns a hook name as v1 built it for the plugin. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -191,13 +208,14 @@ function dws_ic_min_wp(): string { */ function dws_ic_get_hook_tag( string $name, $extra = array() ): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return dws_ic_join_v1_hook_tag( 'internal_comments', $name, $extra ); } /** * Returns a hook name as v1 built it for one of its components, or null for an id v1 had no component for. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -210,7 +228,6 @@ function dws_ic_get_hook_tag( string $name, $extra = array() ): string { */ function dws_ic_get_component_hook_tag( string $component_id, string $name, $extra = array() ): ?string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return isset( DWS_IC_V1_COMPONENTS[ $component_id ] ) ? dws_ic_join_v1_hook_tag( 'internal_comments/' . DWS_IC_V1_COMPONENTS[ $component_id ], $name, $extra ) : null; } diff --git a/plugins/internal-comments/includes/footprint.php b/plugins/internal-comments/includes/footprint.php index 9512fc8..514cce0 100644 --- a/plugins/internal-comments/includes/footprint.php +++ b/plugins/internal-comments/includes/footprint.php @@ -13,7 +13,9 @@ defined( 'ABSPATH' ) || exit; /** - * Returns the plugin's stored data by kind, which covers every option, transient, user meta key and scheduled job under the plugin's prefix, and what v1 stored under its own names: its settings, version rows and notices, the internal comments and the capabilities. + * Returns the plugin's stored data by kind. + * + * It also covers what v1 stored under its own names. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/internal-comments/includes/markup.php b/plugins/internal-comments/includes/markup.php index a975f29..e512902 100644 --- a/plugins/internal-comments/includes/markup.php +++ b/plugins/internal-comments/includes/markup.php @@ -23,7 +23,6 @@ function dws_ic_get_comments_list_html( int $post_id ): string { ob_start(); dws_ic_print_comments_list( $post_id ); - return (string) ob_get_clean(); } @@ -43,12 +42,11 @@ function dws_ic_print_comments_list( int $post_id ): void { echo '
    '; dws_ic_print_comment_thread( $comments, 0 ); echo '
'; - return; } $post_type = get_post_type_object( (string) dws_ic_get_post_type( $post_id ) ); - $singular = null === $post_type ? null : $post_type->labels->singular_name; + $singular = is_null( $post_type ) ? null : $post_type->labels->singular_name; ?>

'Plugin Name' ) )['Name']; - return 0 < did_action( 'init' ) ? __( $name, 'internal-comments' ) : $name; // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- The header's name reaches the POT through make-pot's header extraction. } /** * Returns the plugin's version from its header. * + * @api + * * @since 2.0.0 * @version 2.0.0 * diff --git a/plugins/internal-comments/includes/migrations.php b/plugins/internal-comments/includes/migrations.php index 49c1bed..5c4eb3d 100644 --- a/plugins/internal-comments/includes/migrations.php +++ b/plugins/internal-comments/includes/migrations.php @@ -66,7 +66,9 @@ public function process_batch(): bool { } /** - * Stores each settings row a site lacks with v1's defaults, autoloaded, so a site that never saved the settings page reads them without a query, and keeps the rows a v1 site stored. + * Stores the default of each setting a site lacks. + * + * The rows a v1 site stored stay as they are. * * @since 2.0.0 * @version 2.0.0 @@ -131,7 +133,6 @@ function dws_ic_convert_v1_comments(): bool { if ( $end >= $last ) { delete_option( 'dws_ic_v1_comments_cursor' ); - return false; } diff --git a/plugins/internal-comments/includes/safeguards.php b/plugins/internal-comments/includes/safeguards.php index c60efc1..deee44b 100644 --- a/plugins/internal-comments/includes/safeguards.php +++ b/plugins/internal-comments/includes/safeguards.php @@ -1,6 +1,8 @@ getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The plugin's logger is part of the build that failed. + // A partial deploy fails the same way on every request, so each distinct message is logged about once an hour, as best effort. + $key = 'dws_ic_build_error_' . md5( $error->getMessage() ); + if ( false === get_transient( $key ) ) { + set_transient( $key, 1, HOUR_IN_SECONDS ); + error_log( 'Internal Comments did not finish loading: ' . $error->getMessage() . ' in ' . $error->getFile() . ':' . $error->getLine() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The plugin's logger is part of the build that failed. + } + add_action( 'all_admin_notices', 'dws_ic_render_build_notice' ); } diff --git a/plugins/internal-comments/models/DWS_Internal_Comment.php b/plugins/internal-comments/models/DWS_Internal_Comment.php index 6c7d96b..0387a95 100644 --- a/plugins/internal-comments/models/DWS_Internal_Comment.php +++ b/plugins/internal-comments/models/DWS_Internal_Comment.php @@ -7,6 +7,8 @@ * * It keeps v1's global name, which other plugins type-hint, and reads of the comment's fields and calls to its methods reach the wrapped comment. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -41,7 +43,7 @@ final class DWS_Internal_Comment { */ public function __construct( $comment ) { $comment = dws_ic_get_comment( $comment ); - if ( null === $comment || DWS_IC_COMMENT_TYPE !== $comment->comment_type ) { + if ( is_null( $comment ) || DWS_IC_COMMENT_TYPE !== $comment->comment_type ) { throw new InvalidArgumentException( 'The comment is not an internal comment.' ); } @@ -59,7 +61,7 @@ public function __construct( $comment ) { * @return mixed */ public function __get( string $name ) { - return property_exists( $this->comment, $name ) ? $this->comment->$name : null; + return property_exists( $this->comment, $name ) ? $this->comment->$name : null; // @phpstan-ignore property.dynamicName (v1 forwards every field of the comment by name.) } /** @@ -73,7 +75,7 @@ public function __get( string $name ) { * @return bool */ public function __isset( string $name ): bool { - return isset( $this->comment->$name ); + return isset( $this->comment->$name ); // @phpstan-ignore property.dynamicName (v1 forwards every field of the comment by name.) } /** @@ -88,7 +90,7 @@ public function __isset( string $name ): bool { * @return mixed */ public function __call( string $name, array $args ) { - return method_exists( $this->comment, $name ) ? $this->comment->$name( ...$args ) : null; + return method_exists( $this->comment, $name ) ? $this->comment->$name( ...$args ) : null; // @phpstan-ignore method.dynamicName (v1 forwards every method of the comment by name.) } // endregion @@ -107,7 +109,6 @@ public function __call( string $name, array $args ) { */ public function is_author( ?int $user_id = null ): bool { $user_id = $user_id ?? get_current_user_id(); - return 0 !== $user_id && (int) $this->comment->user_id === $user_id; } @@ -177,7 +178,6 @@ public function can_trash( ?int $user_id = null ): bool { */ public function get_edit_comment_link(): ?string { $link = get_edit_comment_link( (int) $this->comment->comment_ID ); - return is_string( $link ) ? $link : null; } diff --git a/plugins/internal-comments/package.json b/plugins/internal-comments/package.json index 59b1e7e..9d53ece 100644 --- a/plugins/internal-comments/package.json +++ b/plugins/internal-comments/package.json @@ -28,30 +28,50 @@ "setup": "npm run packages-install:all && npm run build && npm run wp-env:start", "packages-install:all": "composer packages-install && npm run packages-install", "packages-update:all": "composer packages-update && npm run packages-update && npm run packages-update:wp", + "packages-install": "npm install", "packages-update": "npm update", "packages-update:wp": "wp-scripts packages-update", - "wp-env": "wp-env", + + "build": "npm run build:assets", + "build:assets": "wp-scripts build meta-box=./assets/src/meta-box.ts quick-view=./assets/src/quick-view.ts --output-path=assets/build", + "start": "npm run start:assets", + "start:assets": "wp-scripts start meta-box=./assets/src/meta-box.ts quick-view=./assets/src/quick-view.ts --output-path=assets/build", + + "format": "npm run format:scripts && npm run format:styles", + "format:scripts": "wp-scripts format assets/src tests/EndToEnd eslint.config.mjs stylelint.config.js playwright.config.js", + "format:styles": "npm run lint:styles -- --fix", + "format:all": "( composer format:php || [ $? -eq 1 ] ) && npm run format", + + "lint": "npm run lint:scripts && npm run lint:styles && npm run lint:types", + "lint:scripts": "wp-scripts lint-js assets/src tests/EndToEnd eslint.config.mjs stylelint.config.js playwright.config.js", + "lint:styles": "wp-scripts lint-style 'assets/src/**/*.scss'", + "lint:types": "tsc", + "lint:all": "composer lint:php && npm run lint", + "wp-env:start": "wp-env start", "wp-env:stop": "wp-env stop", "wp-env:update": "wp-env start --update", "wp-env:destroy": "wp-env destroy", + "wp-env:tests:start": "wp-env --config .wp-env.tests.json start", "wp-env:tests:stop": "wp-env --config .wp-env.tests.json stop", "wp-env:tests:update": "wp-env --config .wp-env.tests.json start --update", "wp-env:tests:destroy": "wp-env --config .wp-env.tests.json destroy", - "build": "npm run build:assets", - "build:assets": "wp-scripts build meta-box=./assets/src/meta-box.ts quick-view=./assets/src/quick-view.ts --output-path=assets/build", - "start": "npm run start:assets", - "start:assets": "wp-scripts start meta-box=./assets/src/meta-box.ts quick-view=./assets/src/quick-view.ts --output-path=assets/build", - "lint": "npm run lint:scripts && npm run lint:styles && npm run lint:types", - "lint:scripts": "wp-scripts lint-js assets/src tests/EndToEnd", - "lint:styles": "wp-scripts lint-style 'assets/src/**/*.scss'", - "lint:types": "tsc", - "audit": "composer audit && npm audit", + + "wp-env:multisite:start": "wp-env --config .wp-env.multisite.json start", + "wp-env:multisite:stop": "wp-env --config .wp-env.multisite.json stop", + "wp-env:multisite:update": "wp-env --config .wp-env.multisite.json start --update", + "wp-env:multisite:destroy": "wp-env --config .wp-env.multisite.json destroy", + + "audit": "composer audit --locked --abandoned=report && npm audit --omit=dev --audit-level=high", "audit:fix": "npm audit fix", "check:engines": "wp-scripts check-engines", "check:licenses": "wp-scripts check-licenses", - "test:e2e": "playwright test --config=playwright.config.js" + + "test:e2e": "playwright test --config=playwright.config.js", + "test:all": "composer test && npm run test:e2e", + + "quality-check:all": "composer quality-check && npm run lint && npm run test:e2e" } } diff --git a/plugins/internal-comments/phpcs.dist.xml b/plugins/internal-comments/phpcs.dist.xml index 9edca1b..9284451 100644 --- a/plugins/internal-comments/phpcs.dist.xml +++ b/plugins/internal-comments/phpcs.dist.xml @@ -25,4 +25,19 @@ + + + + + + + + + + + + + + */models/DWS_Internal_Comment\.php + diff --git a/plugins/internal-comments/phpstan.dist.neon b/plugins/internal-comments/phpstan.dist.neon index f5fa6c4..2e01d39 100644 --- a/plugins/internal-comments/phpstan.dist.neon +++ b/plugins/internal-comments/phpstan.dist.neon @@ -1,4 +1,4 @@ -# The WordPress extensions are listed here because composer.json keeps them from installing themselves, and before configs' profile because wp-compat's neon resets WPCompat.requiresAtLeast to null. +# The WordPress extensions are listed here because composer.json keeps them from installing themselves. includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon - vendor/johnbillion/wp-compat/extension.neon @@ -7,14 +7,23 @@ includes: parameters: paths: - - internal-comments.php - functions.php - uninstall.php - includes + - models - src + # phpstan.safeguards.neon analyzes the entry and the safeguards at the oldest runtime they parse on, so this profile only scans them. + excludePaths: + - includes/safeguards.php scanFiles: + - internal-comments.php + - includes/safeguards.php - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php - vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php # The scoped packages load through vendor-prefixed/scoper-autoload.php, which PHPStan does not read. scanDirectories: - vendor-prefixed + # wp-compat reads the floor from the entry's Requires at least only while requiresAtLeast, which configs' profile sets, is null. + WPCompat: + pluginFile: internal-comments.php + requiresAtLeast: null diff --git a/plugins/internal-comments/phpstan.safeguards.neon b/plugins/internal-comments/phpstan.safeguards.neon index e2f6abc..99f3cb7 100644 --- a/plugins/internal-comments/phpstan.safeguards.neon +++ b/plugins/internal-comments/phpstan.safeguards.neon @@ -1,4 +1,4 @@ -# The safeguards load before the floor gate, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on, which the framework's phpstan.bootstrap.neon sets; the WordPress extensions are listed because composer.json keeps them from installing themselves. +# The entry and the safeguards run before the floor gate, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on, which the framework's phpstan.bootstrap.neon sets; the WordPress extensions are listed because composer.json keeps them from installing themselves. includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon - vendor/johnbillion/wp-compat/extension.neon @@ -7,7 +7,20 @@ includes: parameters: paths: + - internal-comments.php - includes/safeguards.php + # A safeguard calls WooCommerce where the plugin requires it. + scanFiles: + - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php phpVersion: 70400 WPCompat: requiresAtLeast: '5.2' + # The entry loads the scoped bootstrap package by path, before any autoloader. + scanDirectories: + - vendor-prefixed/ahegyes/wp-framework-bootstrap + ignoreErrors: + # The entry's build-error handler runs only once the floor gate has passed. + - + identifier: WPCompat.functionNotAvailable + message: '#^wp_get_environment_type\(\) #' + path: internal-comments.php diff --git a/plugins/internal-comments/phpunit.dist.xml b/plugins/internal-comments/phpunit.dist.xml index c04a1e5..62148d3 100644 --- a/plugins/internal-comments/phpunit.dist.xml +++ b/plugins/internal-comments/phpunit.dist.xml @@ -27,6 +27,8 @@ src + includes + models diff --git a/plugins/internal-comments/playwright.config.js b/plugins/internal-comments/playwright.config.js index 4a295de..e1fdf65 100644 --- a/plugins/internal-comments/playwright.config.js +++ b/plugins/internal-comments/playwright.config.js @@ -1,6 +1,8 @@ const { defineConfig } = require( '@playwright/test' ); -// The port of .wp-env.tests.json, so the suite runs on the production tests environment and leaves the development one's data alone. -const baseConfig = require( '@ahegyes/wordpress-configs/node/playwright.config.base.js' )( { port: 8825 } ); +const baseConfig = + require( '@ahegyes/wordpress-configs/node/playwright.config.base.js' )( { + port: require( './.wp-env.tests.json' ).port, + } ); module.exports = defineConfig( { ...baseConfig, diff --git a/plugins/internal-comments/scoper.inc.php b/plugins/internal-comments/scoper.inc.php index 3e718e7..5bd6fe0 100644 --- a/plugins/internal-comments/scoper.inc.php +++ b/plugins/internal-comments/scoper.inc.php @@ -2,6 +2,9 @@ /** * Returns the php-scoper config that prefixes the framework and PHP-DI with the plugin's namespace. * + * @since 2.0.0 + * @version 2.0.0 + * * @package DeepWebSolutions\InternalComments */ diff --git a/plugins/internal-comments/src/AdminScreens.php b/plugins/internal-comments/src/AdminScreens.php index 53c47c7..43138ac 100644 --- a/plugins/internal-comments/src/AdminScreens.php +++ b/plugins/internal-comments/src/AdminScreens.php @@ -108,7 +108,6 @@ public function add_column( array $columns, string $post_type ): array { $position = \array_search( $preceding, \array_keys( $columns ), true ); $position = false === $position ? \count( $columns ) : $position + 1; - return \array_slice( $columns, 0, $position, true ) + array( self::COLUMN => __( 'Internal Comments', 'internal-comments' ) ) + \array_slice( $columns, $position, null, true ); } @@ -202,7 +201,6 @@ public function render_cell( string $column, int $post_id ): void { } if ( 0 === $count ) { echo '' . esc_html__( 'No internal comments', 'internal-comments' ) . ''; - return; } @@ -249,7 +247,7 @@ public function render_cell( string $column, int $post_id ): void { */ public function enqueue_list_assets( string $hook_suffix ): void { $screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null; - $post_type = null === $screen || '' === $screen->post_type ? 'post' : $screen->post_type; + $post_type = \is_null( $screen ) || '' === $screen->post_type ? 'post' : $screen->post_type; if ( 1 === \preg_match( '/^woocommerce_page_wc-orders(?:--(.+))?$/', $hook_suffix, $matches ) && ! isset( $_GET['action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tells the list from the edit screen. $post_type = $matches[1] ?? 'shop_order'; } elseif ( 'edit.php' !== $hook_suffix ) { @@ -314,7 +312,6 @@ public function filter_list_table_args( array $args ): array { $args['dws_ic_list_table'] = true; if ( ! current_user_can( DWS_IC_CAP_SEE_TABLE ) ) { $args['type__not_in'] = dws_ic_exclude_internal_type( $args['type__not_in'] ?? array() ); - return $args; } @@ -377,7 +374,6 @@ public function filter_row_actions( array $actions, \WP_Comment $comment ): arra */ public function filter_bulk_actions( array $actions ): array { $type = isset( $_GET['comment_type'] ) && \is_string( $_GET['comment_type'] ) ? sanitize_key( wp_unslash( $_GET['comment_type'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads the screen's view. - return DWS_IC_COMMENT_TYPE === $type ? \array_intersect_key( $actions, \array_flip( array( 'trash', 'untrash', 'delete' ) ) ) : $actions; } @@ -426,7 +422,6 @@ public function count_listed_comments( mixed $count, int $post_id ): mixed { $counts = \get_object_vars( $count ); $counts['trash'] = \is_int( $listed ) ? $listed : 0; - return (object) $counts; } @@ -446,7 +441,6 @@ public function leave_out_internal_type( array $clauses ): array { global $wpdb; $clauses['where'] = ( $clauses['where'] ?? '' ) . ( $wpdb->prepare( ' AND %i.comment_type <> %s', $wpdb->comments, DWS_IC_COMMENT_TYPE ) ?? '' ); - return $clauses; } @@ -503,7 +497,6 @@ public function render_revisions( \WP_Comment $comment ): void { $changes = \array_reverse( \array_filter( (array) get_comment_meta( $comment_id, '_dws_ic_change_id', false ), 'is_string' ) ); if ( array() === $changes ) { echo '

' . esc_html__( 'No earlier versions of this comment are recorded.', 'internal-comments' ) . '

'; - return; } @@ -512,8 +505,8 @@ public function render_revisions( \WP_Comment $comment ): void { echo '
    '; foreach ( $changes as $change_id ) { $content = get_comment_meta( $comment_id, "_dws_ic_previous_content_$change_id", true ); - $user = get_userdata( filter_var( get_comment_meta( $comment_id, "_dws_ic_changed_by_$change_id", true ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? 0 ); - $time = filter_var( get_comment_meta( $comment_id, "_dws_ic_changed_time_$change_id", true ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? 0; + $user = get_userdata( \filter_var( get_comment_meta( $comment_id, "_dws_ic_changed_by_$change_id", true ), \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE ) ?? 0 ); + $time = \filter_var( get_comment_meta( $comment_id, "_dws_ic_changed_time_$change_id", true ), \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE ) ?? 0; $name = false === $user ? __( 'an unknown user', 'internal-comments' ) : $user->display_name; ?>
  • @@ -550,7 +543,7 @@ public function render_revisions( \WP_Comment $comment ): void { */ public function add_privacy_field( string $html, \WP_Comment $comment ): string { $is_private = dws_ic_is_private_comment( $comment ); - if ( null === $is_private || ( ! $is_private && ! dws_ic_are_enabled_private_comments() ) ) { + if ( \is_null( $is_private ) || ( ! $is_private && ! dws_ic_are_enabled_private_comments() ) ) { return $html; } diff --git a/plugins/internal-comments/src/Comments.php b/plugins/internal-comments/src/Comments.php index ce862f1..13d2188 100644 --- a/plugins/internal-comments/src/Comments.php +++ b/plugins/internal-comments/src/Comments.php @@ -70,12 +70,12 @@ public function keep_internal( int|string $comment_id ): void { */ public function keep_inserted_internal( int|string $comment_id ): void { $replaced = $this->put_back( $comment_id ); - if ( null === $replaced ) { + if ( \is_null( $replaced ) ) { return; } $comment = dws_ic_get_comment( $comment_id ); - if ( null !== $comment ) { + if ( ! \is_null( $comment ) ) { wp_transition_comment_status( DWS_IC_COMMENT_STATUS, $replaced, $comment ); } } @@ -98,7 +98,7 @@ public function announce_kept_internal( int|string $new_status, int|string $old_ } $stored = dws_ic_get_comment( (int) $comment->comment_ID ); - if ( null !== $stored && DWS_IC_COMMENT_STATUS === $stored->comment_approved ) { + if ( ! \is_null( $stored ) && DWS_IC_COMMENT_STATUS === $stored->comment_approved ) { wp_transition_comment_status( DWS_IC_COMMENT_STATUS, (string) $new_status, $stored ); } } @@ -155,11 +155,11 @@ public function keep_post_comments_internal( int $post_id ): void { * @return array|\WP_Error */ public function filter_update_data( array|\WP_Error $data, array $comment ): array|\WP_Error { - if ( \is_wp_error( $data ) || DWS_IC_COMMENT_TYPE !== ( $comment['comment_type'] ?? null ) ) { + if ( is_wp_error( $data ) || DWS_IC_COMMENT_TYPE !== ( $comment['comment_type'] ?? null ) ) { return $data; } - $comment_id = (int) filter_var( $comment['comment_ID'] ?? 0, FILTER_VALIDATE_INT ); + $comment_id = (int) \filter_var( $comment['comment_ID'] ?? 0, \FILTER_VALIDATE_INT ); $previous = wp_unslash( $comment['comment_content'] ?? '' ); if ( ( $data['comment_content'] ?? '' ) !== $previous ) { $change_id = \uniqid( (string) get_current_user_id(), true ); @@ -187,7 +187,7 @@ public function filter_update_data( array|\WP_Error $data, array $comment ): arr * @return void */ public function keep_replies_private( int|string $comment_id ): void { - if ( true !== dws_ic_is_internal_comment( $comment_id ) || true !== filter_var( get_comment_meta( (int) $comment_id, DWS_IC_META_PRIVATE, true ), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ) { + if ( true !== dws_ic_is_internal_comment( $comment_id ) || true !== \filter_var( get_comment_meta( (int) $comment_id, DWS_IC_META_PRIVATE, true ), \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE ) ) { return; } @@ -243,7 +243,6 @@ public function hide_unmoved_comments( \WP_Comment_Query $query ): void { */ public function hide_unmoved_feed_comments( string $where ): string { global $wpdb; - return dws_ic_is_v1_move_pending() ? $where . ( $wpdb->prepare( ' AND %i.comment_type <> %s', $wpdb->comments, DWS_IC_COMMENT_TYPE ) ?? '' ) : $where; } @@ -266,7 +265,7 @@ public function guard_privacy( mixed $check, int $comment_id, string $meta_key, } // A comment turns private only before anyone replies, since a reply's author would otherwise own the private thread once the comment above is deleted. - $turns_private = true === filter_var( $meta_value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) && true !== filter_var( get_comment_meta( $comment_id, DWS_IC_META_PRIVATE, true ), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ); + $turns_private = true === \filter_var( $meta_value, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE ) && true !== \filter_var( get_comment_meta( $comment_id, DWS_IC_META_PRIVATE, true ), \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE ); if ( $turns_private && dws_ic_has_replies( $comment_id ) ) { return false; } @@ -276,8 +275,7 @@ public function guard_privacy( mixed $check, int $comment_id, string $meta_key, } $comment = dws_ic_get_comment( $comment_id ); - - return null !== $comment && get_current_user_id() !== (int) $comment->user_id ? false : $check; + return ! \is_null( $comment ) && get_current_user_id() !== (int) $comment->user_id ? false : $check; } // endregion @@ -300,14 +298,13 @@ protected function put_back( int|string $comment_id ): ?string { global $wpdb; $comment = dws_ic_get_comment( $comment_id ); - if ( null === $comment || DWS_IC_COMMENT_TYPE !== $comment->comment_type || \in_array( $comment->comment_approved, array( DWS_IC_COMMENT_STATUS, 'trash', 'post-trashed' ), true ) ) { + if ( \is_null( $comment ) || DWS_IC_COMMENT_TYPE !== $comment->comment_type || \in_array( $comment->comment_approved, array( DWS_IC_COMMENT_STATUS, 'trash', 'post-trashed' ), true ) ) { return null; } $wpdb->update( $wpdb->comments, array( 'comment_approved' => DWS_IC_COMMENT_STATUS ), array( 'comment_ID' => (int) $comment->comment_ID ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- wp_set_comment_status() refuses a status of its own, and the cache is cleaned below. clean_comment_cache( (int) $comment->comment_ID ); wp_update_comment_count( (int) $comment->comment_post_ID ); - return $comment->comment_approved; } diff --git a/plugins/internal-comments/src/Permissions.php b/plugins/internal-comments/src/Permissions.php index e816d90..d80f109 100644 --- a/plugins/internal-comments/src/Permissions.php +++ b/plugins/internal-comments/src/Permissions.php @@ -62,7 +62,7 @@ public function map_meta_cap( array $caps, string $cap, int $user_id, array $arg $comment = dws_ic_get_comment( $args[0] ?? null ); $owners = dws_ic_get_private_comment_owners( $comment ); - if ( null === $comment || null === $owners ) { + if ( \is_null( $comment ) || \is_null( $owners ) ) { return array( 'do_not_allow' ); } @@ -102,7 +102,7 @@ public function guard_core_rest_comment( mixed $response, mixed $handler, \WP_RE default => null, } : null; $comment_id = \is_numeric( $request['id'] ) ? (int) $request['id'] : 0; - if ( \is_wp_error( $response ) || null === $capability || true !== dws_ic_is_internal_comment( $comment_id ) || current_user_can( $capability, $comment_id ) ) { + if ( is_wp_error( $response ) || \is_null( $capability ) || true !== dws_ic_is_internal_comment( $comment_id ) || current_user_can( $capability, $comment_id ) ) { return $response; } @@ -121,7 +121,6 @@ public function guard_core_rest_comment( mixed $response, mixed $handler, \WP_RE */ public function exclude_from_core_rest_collections( array $args ): array { $args['type__not_in'] = dws_ic_exclude_internal_type( $args['type__not_in'] ?? array() ); - return $args; } diff --git a/plugins/internal-comments/src/Plugin.php b/plugins/internal-comments/src/Plugin.php index 51e28f4..9327fb2 100644 --- a/plugins/internal-comments/src/Plugin.php +++ b/plugins/internal-comments/src/Plugin.php @@ -16,7 +16,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Composes the plugin from its logger, scheduler, migrations and components, inside the framework's boot boundary. + * Composes the plugin inside the framework's boot boundary. * * @since 2.0.0 * @version 2.0.0 @@ -72,7 +72,7 @@ final class Plugin { * * @var PluginBoot */ - public readonly PluginBoot $boot; + public readonly PluginBoot $boundary; /** * The scheduler, which the components and the deactivation callback share. @@ -127,7 +127,7 @@ final class Plugin { public function __construct() { $this->logger = new ErrorLogLogger( self::SLUG ); $this->scheduler = new Scheduler( self::PREFIX ); - $this->boot = new PluginBoot( DWS_IC_FILE, self::PREFIX, $this->logger ); + $this->boundary = new PluginBoot( DWS_IC_FILE, self::PREFIX, $this->logger ); } // endregion @@ -143,7 +143,7 @@ public function __construct() { * @return void */ public function boot(): void { - $this->boot->run( $this->compose( ... ) ); + $this->boundary->run( $this->compose( ... ) ); } /** @@ -191,7 +191,6 @@ protected function compose(): bool { $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); - return true; } diff --git a/plugins/internal-comments/src/RestRoutes.php b/plugins/internal-comments/src/RestRoutes.php index 854146c..c395aa6 100644 --- a/plugins/internal-comments/src/RestRoutes.php +++ b/plugins/internal-comments/src/RestRoutes.php @@ -135,7 +135,6 @@ public function register_routes(): void { */ public function can_read_post_comments( \WP_REST_Request $request ): bool { $post_id = absint( $request['post'] ); - return $this->can_use_post( $post_id ) && ( current_user_can( DWS_IC_CAP_SEE_METABOX ) || current_user_can( DWS_IC_CAP_SEE_COLUMN ) ); } @@ -214,7 +213,7 @@ public function create_comment( \WP_REST_Request $request ): \WP_REST_Response|\ } $parent = 0 === $parent_id ? null : dws_ic_get_comment( $parent_id ); - if ( 0 !== $parent_id && ( null === $parent || DWS_IC_COMMENT_TYPE !== $parent->comment_type || (int) $parent->comment_post_ID !== $post_id || ! current_user_can( 'see_dws_internal_comment', $parent_id ) ) ) { + if ( 0 !== $parent_id && ( \is_null( $parent ) || DWS_IC_COMMENT_TYPE !== $parent->comment_type || (int) $parent->comment_post_ID !== $post_id || ! current_user_can( 'see_dws_internal_comment', $parent_id ) ) ) { return new \WP_Error( 'dws_ic_invalid_parent', __( 'The comment you are replying to is not one of this item\'s internal comments.', 'internal-comments' ), array( 'status' => 400 ) ); } @@ -253,7 +252,7 @@ public function create_comment( \WP_REST_Request $request ): \WP_REST_Response|\ 'comment_meta' => array( DWS_IC_META_PRIVATE => $private ? 'yes' : 'no' ), ) ); - if ( null === $comment_id ) { + if ( \is_null( $comment_id ) ) { return new \WP_Error( 'dws_ic_not_stored', __( 'The internal comment could not be stored. Please try again.', 'internal-comments' ), array( 'status' => 500 ) ); } @@ -272,11 +271,10 @@ public function create_comment( \WP_REST_Request $request ): \WP_REST_Response|\ */ public function get_comment( \WP_REST_Request $request ): \WP_REST_Response { $comment = dws_ic_get_comment( absint( $request['id'] ) ); - return new \WP_REST_Response( array( 'id' => absint( $request['id'] ), - 'content' => null === $comment ? '' : $comment->comment_content, + 'content' => \is_null( $comment ) ? '' : $comment->comment_content, ) ); } @@ -299,7 +297,7 @@ public function update_comment( \WP_REST_Request $request ): \WP_REST_Response|\ } $stored = dws_ic_get_comment( $comment_id ); - $comment = null === $stored ? array() : $stored->to_array(); + $comment = \is_null( $stored ) ? array() : $stored->to_array(); $data = array( 'comment_ID' => $comment_id, 'comment_content' => $content, @@ -353,11 +351,11 @@ public function update_comment( \WP_REST_Request $request ): \WP_REST_Response|\ do_action( 'dws_ic/internal_comment/before_update', $comment, $data ); $updated = wp_update_comment( wp_slash( $data ), true ); - if ( \is_wp_error( $updated ) || false === $updated ) { + if ( is_wp_error( $updated ) || false === $updated ) { return new \WP_Error( 'dws_ic_not_updated', __( 'The internal comment could not be updated. Please try again.', 'internal-comments' ), array( 'status' => 500 ) ); } - return $this->respond( null === $stored ? 0 : (int) $stored->comment_post_ID ); + return $this->respond( \is_null( $stored ) ? 0 : (int) $stored->comment_post_ID ); } /** @@ -373,7 +371,7 @@ public function update_comment( \WP_REST_Request $request ): \WP_REST_Response|\ public function trash_comment( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $comment_id = absint( $request['id'] ); $stored = dws_ic_get_comment( $comment_id ); - $comment = null === $stored ? array() : $stored->to_array(); + $comment = \is_null( $stored ) ? array() : $stored->to_array(); /** * Fires before the meta box trashes an internal comment. @@ -400,7 +398,7 @@ public function trash_comment( \WP_REST_Request $request ): \WP_REST_Response|\W return new \WP_Error( 'dws_ic_not_trashed', __( 'The internal comment could not be moved to the trash. Please try again.', 'internal-comments' ), array( 'status' => 500 ) ); } - return $this->respond( null === $stored ? 0 : (int) $stored->comment_post_ID ); + return $this->respond( \is_null( $stored ) ? 0 : (int) $stored->comment_post_ID ); } // endregion diff --git a/plugins/internal-comments/src/Settings.php b/plugins/internal-comments/src/Settings.php index 8a48e05..6b4b352 100644 --- a/plugins/internal-comments/src/Settings.php +++ b/plugins/internal-comments/src/Settings.php @@ -116,7 +116,6 @@ public function get_options_page( string $page_id ): array { */ public function add_settings_link( array $links ): array { \array_unshift( $links, \sprintf( '%s', esc_url( admin_url( 'options-general.php?page=' . self::PAGE ) ), esc_html__( 'Settings', 'internal-comments' ) ) ); - return $links; } diff --git a/plugins/internal-comments/stylelint.config.js b/plugins/internal-comments/stylelint.config.js index 84c3761..23802a6 100644 --- a/plugins/internal-comments/stylelint.config.js +++ b/plugins/internal-comments/stylelint.config.js @@ -1,3 +1 @@ -const baseConfig = require( '@ahegyes/wordpress-configs/node/stylelint.config.base.js' ); - -module.exports = { ...baseConfig }; +module.exports = require( '@ahegyes/wordpress-configs/node/stylelint.config.base.js' ); diff --git a/plugins/internal-comments/tests/EndToEnd/cli.ts b/plugins/internal-comments/tests/EndToEnd/cli.ts new file mode 100644 index 0000000..14aabda --- /dev/null +++ b/plugins/internal-comments/tests/EndToEnd/cli.ts @@ -0,0 +1,13 @@ +/// +import { execFileSync } from 'node:child_process'; + +// No screen shows some of what the specs set up or check, so they run WP-CLI on the tests site and read its last line. +export const cli = ( ...args: string[] ): string => + execFileSync( + 'node_modules/.bin/wp-env', + [ '--config', '.wp-env.tests.json', 'run', 'cli', 'wp', ...args ], + { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] } + ) + .trim() + .split( '\n' ) + .pop() ?? ''; diff --git a/plugins/internal-comments/tests/EndToEnd/settings.ts b/plugins/internal-comments/tests/EndToEnd/settings.ts index 0f843fe..3490495 100644 --- a/plugins/internal-comments/tests/EndToEnd/settings.ts +++ b/plugins/internal-comments/tests/EndToEnd/settings.ts @@ -1,22 +1,12 @@ -/// -import { execFileSync } from 'node:child_process'; +import { cli } from './cli'; // No REST route exposes the plugin's settings, so the site's WP-CLI stores them. export const storeSettings = ( value: Record< string, unknown > ): void => { - execFileSync( - 'node_modules/.bin/wp-env', - [ - '--config', - '.wp-env.tests.json', - 'run', - 'cli', - 'wp', - 'option', - 'update', - 'dws-internal-comments_general', - JSON.stringify( value ), - '--format=json', - ], - { stdio: 'ignore' } + cli( + 'option', + 'update', + 'dws-internal-comments_general', + JSON.stringify( value ), + '--format=json' ); }; diff --git a/plugins/internal-comments/tests/EndToEnd/without-woocommerce.spec.ts b/plugins/internal-comments/tests/EndToEnd/without-woocommerce.spec.ts index 35caba3..10c9f5c 100644 --- a/plugins/internal-comments/tests/EndToEnd/without-woocommerce.spec.ts +++ b/plugins/internal-comments/tests/EndToEnd/without-woocommerce.spec.ts @@ -1,25 +1,5 @@ -/// -import { execFileSync } from 'node:child_process'; import { expect, test } from '@wordpress/e2e-test-utils-playwright'; - -// No page shows the boot's outcome, so the site's WP-CLI reads it. -const bootStatus = (): string => - execFileSync( - 'node_modules/.bin/wp-env', - [ - '--config', - '.wp-env.tests.json', - 'run', - 'cli', - 'wp', - 'eval', - 'echo dws_ic_plugin()->boot->status;', - ], - { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] } - ) - .trim() - .split( '\n' ) - .pop() ?? ''; +import { cli } from './cli'; test.describe( 'Without WooCommerce', () => { test.beforeAll( async ( { requestUtils } ) => { @@ -33,7 +13,9 @@ test.describe( 'Without WooCommerce', () => { test( 'the plugin boots and every screen still loads', async ( { page, } ) => { - expect( bootStatus() ).toBe( 'booted' ); + expect( cli( 'eval', 'echo dws_ic_plugin()->boundary->status;' ) ).toBe( + 'booted' + ); const front = await page.goto( '/' ); expect( front?.status() ).toBe( 200 ); diff --git a/plugins/internal-comments/tests/Integration/AdminScreensTest.php b/plugins/internal-comments/tests/Integration/AdminScreensTest.php index b48e65a..8df558e 100644 --- a/plugins/internal-comments/tests/Integration/AdminScreensTest.php +++ b/plugins/internal-comments/tests/Integration/AdminScreensTest.php @@ -3,13 +3,16 @@ namespace DeepWebSolutions\InternalComments\Tests\Integration; use DeepWebSolutions\InternalComments\AdminScreens; +use DeepWebSolutions\InternalComments\Tests\Support\RecordsDeprecationsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( AdminScreens::class )] final class AdminScreensTest extends TestCase { + use RecordsDeprecationsTrait; + // WordPress reports a deprecated hook only when something listens on it. protected const string V1_PRECEDING_COLUMN = 'internal_comments/wp_table_output/insert_after'; @@ -19,8 +22,6 @@ final class AdminScreensTest extends TestCase { protected mixed $stored; - protected array $deprecated = array(); - #[\Override] public static function setUpBeforeClass(): void { require_once ABSPATH . 'wp-admin/includes/user.php'; @@ -57,8 +58,6 @@ protected function create_post(): void { ) ); wp_set_current_user( self::$users['administrator'] ); - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ), 10, 2 ); } #[After] @@ -67,31 +66,23 @@ protected function delete_post(): void { update_option( DWS_IC_OPTION_GENERAL, $this->stored ); wp_set_current_user( 0 ); unset( $_GET['comment_type'] ); - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ) ); - } - - public function record_deprecated_hook( string $hook, string $replacement ): void { - $this->deprecated[] = array( $hook, $replacement ); } public function test_the_posts_list_column_follows_the_comments_column_and_passes_v1s_filter_first(): void { $seen = array(); $v1 = static function ( string $column, ?string $post_type ) use ( &$seen ): string { $seen[] = array( 'v1', $column, $post_type ); - return $column; }; $v2 = static function ( string $column, ?string $post_type ) use ( &$seen ): string { $seen[] = array( 'v2', $column, $post_type ); - return 'title'; }; add_filter( self::V1_PRECEDING_COLUMN, $v1, 10, 2 ); add_filter( 'dws_ic/list_table/preceding_column', $v2, 10, 2 ); try { - $columns = ( new AdminScreens() )->add_column( + $columns = new AdminScreens()->add_column( array( 'cb' => '', 'title' => 'Title', @@ -109,8 +100,8 @@ public function test_the_posts_list_column_follows_the_comments_column_and_passe self::assertSame( array( array( 'v1', 'comments', 'post' ), array( 'v2', 'comments', 'post' ) ), $seen ); self::assertSame( array( array( self::V1_PRECEDING_COLUMN, 'dws_ic/list_table/preceding_column' ) ), $this->deprecated ); wp_set_current_user( self::$users['editor'] ); - self::assertArrayHasKey( 'dws-internal-comments', ( new AdminScreens() )->add_column( array( 'title' => 'Title' ), 'post' ) ); - self::assertSame( array( 'title' => 'Title' ), ( new AdminScreens() )->add_column( array( 'title' => 'Title' ), 'page' ) ); + self::assertArrayHasKey( 'dws-internal-comments', new AdminScreens()->add_column( array( 'title' => 'Title' ), 'post' ) ); + self::assertSame( array( 'title' => 'Title' ), new AdminScreens()->add_column( array( 'title' => 'Title' ), 'page' ) ); } // WooCommerce rebuilds the columns of its orders and coupons lists on that hook at the default priority. @@ -189,14 +180,14 @@ public function test_the_comments_screen_leaves_out_the_rows_its_reader_cannot_s wp_set_current_user( self::$users['editor'] ); try { - $args = ( new AdminScreens() )->filter_list_table_args( + $args = new AdminScreens()->filter_list_table_args( array( 'post_id' => $this->post, 'type' => DWS_IC_COMMENT_TYPE, 'status' => 'all', ) ); - $rows = ( new \WP_Comment_Query( $args ) )->comments; + $rows = new \WP_Comment_Query( $args )->comments; $all = get_comments( array( 'post_id' => $this->post, 'type' => DWS_IC_COMMENT_TYPE, 'status' => DWS_IC_COMMENT_STATUS ) ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound -- A query's three arguments. } finally { get_role( 'editor' )?->remove_cap( DWS_IC_CAP_SEE_TABLE ); @@ -208,10 +199,10 @@ public function test_the_comments_screen_leaves_out_the_rows_its_reader_cannot_s public function test_the_comments_screen_offers_only_the_actions_an_internal_comment_supports(): void { $comment = get_comment( $this->comment( 'Row' ) ); - $actions = array_fill_keys( array( 'approve', 'unapprove', 'reply', 'quickedit', 'edit', 'spam', 'trash', 'untrash', 'delete' ), '' ); + $actions = \array_fill_keys( array( 'approve', 'unapprove', 'reply', 'quickedit', 'edit', 'spam', 'trash', 'untrash', 'delete' ), '' ); self::assertInstanceOf( \WP_Comment::class, $comment ); - self::assertSame( array( 'edit', 'trash', 'untrash', 'delete' ), \array_keys( ( new AdminScreens() )->filter_row_actions( $actions, $comment ) ) ); + self::assertSame( array( 'edit', 'trash', 'untrash', 'delete' ), \array_keys( new AdminScreens()->filter_row_actions( $actions, $comment ) ) ); } public function test_the_edit_screen_of_an_internal_comment_shows_its_revisions_and_its_author_its_privacy(): void { @@ -232,7 +223,7 @@ public function test_the_edit_screen_of_an_internal_comment_shows_its_revisions_ ); $screens = new AdminScreens(); - $wp_meta_boxes = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The row registers the meta boxes anew. + $wp_meta_boxes = array(); $screens->add_revisions_meta_box( get_comment( $comment ) ); $box = $wp_meta_boxes['comment']['normal']['default']['dws-internal-comments-revisions'] ?? null; self::assertIsArray( $box ); @@ -266,10 +257,9 @@ public function test_the_edit_screen_of_an_internal_comment_shows_its_revisions_ public function test_the_comment_edit_screen_hides_the_fields_an_internal_comment_keeps(): void { $screens = new AdminScreens(); $styles = static function ( int $comment ) use ( $screens ): string { - $GLOBALS['comment'] = get_comment( $comment ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stands in for the edit screen's comment. + $GLOBALS['comment'] = get_comment( $comment ); try { $screens->enqueue_comment_screen_style( 'comment.php' ); - return \implode( '', (array) wp_styles()->get_data( 'internal-comments-comment-screen', 'after' ) ); } finally { unset( $GLOBALS['comment'] ); @@ -291,14 +281,13 @@ public function test_the_comments_screen_counts_only_the_trashed_comments_the_us $public = (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ); wp_trash_comment( $internal ); wp_trash_comment( $public ); - $GLOBALS['current_screen'] = \WP_Screen::get( 'edit-comments' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Sets the screen without firing current_screen, which other plugins hook with admin-only code. + $GLOBALS['current_screen'] = \WP_Screen::get( 'edit-comments' ); $counts = static function (): array { wp_set_current_user( self::$users['editor'] ); wp_get_current_user()->get_role_caps(); // An earlier row can leave the editor current with capabilities its role has since lost. $editor = (int) wp_count_comments()->trash; wp_set_current_user( self::$users['administrator'] ); - return array( $editor, (int) wp_count_comments()->trash ); }; $woocommerce = has_filter( 'wp_count_comments', array( 'WC_Comments', 'wp_count_comments' ) ); @@ -335,7 +324,7 @@ public function test_the_comments_screen_counts_trashed_comments_as_woocommerce_ ); wp_trash_comment( (int) dws_ic_create_internal_comment( array( 'comment_post_ID' => $product ), get_user_by( 'id', self::$users['administrator'] ) ?: null ) ); wp_trash_comment( (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ) ); - $GLOBALS['current_screen'] = \WP_Screen::get( 'edit-comments' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Sets the screen without firing current_screen, which other plugins hook with admin-only code. + $GLOBALS['current_screen'] = \WP_Screen::get( 'edit-comments' ); \WC_Comments::delete_comments_count_cache(); wp_cache_delete( 'comments-0', 'counts' ); @@ -362,7 +351,7 @@ public function test_the_comments_screen_counts_trashed_comments_once_while_v1s_ wp_trash_comment( (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ) ); $revision = get_option( 'dws_ic_revision' ); update_option( 'dws_ic_revision', 1 ); - $GLOBALS['current_screen'] = \WP_Screen::get( 'edit-comments' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Sets the screen without firing current_screen, which other plugins hook with admin-only code. + $GLOBALS['current_screen'] = \WP_Screen::get( 'edit-comments' ); \WC_Comments::delete_comments_count_cache(); wp_cache_delete( 'comments-0', 'counts' ); @@ -384,7 +373,7 @@ public function test_the_comments_screen_counts_trashed_comments_once_while_v1s_ } public function test_the_posts_list_loads_the_quick_views_assets(): void { - ( new AdminScreens() )->enqueue_list_assets( 'edit.php' ); + new AdminScreens()->enqueue_list_assets( 'edit.php' ); self::assertTrue( wp_script_is( 'internal-comments-quick-view' ) ); self::assertTrue( wp_style_is( 'internal-comments-quick-view' ) ); @@ -394,8 +383,7 @@ public function test_the_posts_list_loads_the_quick_views_assets(): void { protected function cell(): \Dom\HTMLDocument { \ob_start(); - ( new AdminScreens() )->render_cell( 'dws-internal-comments', $this->post ); - + new AdminScreens()->render_cell( 'dws-internal-comments', $this->post ); return \Dom\HTMLDocument::createFromString( '' . \ob_get_clean() . '', \LIBXML_NOERROR ); } diff --git a/plugins/internal-comments/tests/Integration/AssetsTest.php b/plugins/internal-comments/tests/Integration/AssetsTest.php index cfb8feb..34a6b28 100644 --- a/plugins/internal-comments/tests/Integration/AssetsTest.php +++ b/plugins/internal-comments/tests/Integration/AssetsTest.php @@ -34,12 +34,12 @@ public function test_a_script_registers_with_its_generated_dependencies_and_vers } public function test_a_stylesheet_registers_with_its_generated_version_and_its_right_to_left_variant(): void { - self::assertTrue( dws_ic_register_style( self::HANDLE, 'tests/Fixtures/built.css', 'print' ) ); + self::assertTrue( dws_ic_register_style( self::HANDLE, 'tests/Fixtures/built.css' ) ); $style = wp_styles()->registered[ self::HANDLE ]; self::assertSame( plugins_url( 'tests/Fixtures/built.css', DWS_IC_FILE ), $style->src ); self::assertSame( 'fixture', $style->ver ); - self::assertSame( 'print', $style->args ); + self::assertSame( 'all', $style->args ); self::assertSame( 'replace', $style->extra['rtl'] ?? null ); } } diff --git a/plugins/internal-comments/tests/Integration/CommentsTest.php b/plugins/internal-comments/tests/Integration/CommentsTest.php index 8470099..e95dc20 100644 --- a/plugins/internal-comments/tests/Integration/CommentsTest.php +++ b/plugins/internal-comments/tests/Integration/CommentsTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\InternalComments\Tests\Integration; +use DeepWebSolutions\InternalComments\Tests\Support\RecordsDeprecationsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; @@ -9,12 +10,12 @@ #[CoversNothing] final class CommentsTest extends TestCase { + use RecordsDeprecationsTrait; + protected int $post; protected int $admin; - protected array $deprecated = array(); - #[Before] protected function create_post(): void { require_once ABSPATH . 'wp-admin/includes/user.php'; @@ -34,8 +35,6 @@ protected function create_post(): void { 'post_status' => 'publish', ) ); - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ), 10, 2 ); } #[After] @@ -43,12 +42,6 @@ protected function delete_post(): void { wp_delete_post( $this->post, true ); wp_set_current_user( 0 ); wp_delete_user( $this->admin ); - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ) ); - } - - public function record_deprecated_hook( string $hook, string $replacement ): void { - $this->deprecated[] = array( $hook, $replacement ); } public function test_an_internal_comment_keeps_v1s_type_under_its_own_status_by_its_author(): void { @@ -75,13 +68,11 @@ public function test_the_creation_data_passes_v1s_filter_then_the_v2_filter(): v $seen = array(); $v1 = static function ( array $data ) use ( &$seen ): array { $seen[] = 'v1'; - return array( 'comment_content' => 'v1' ) + $data; }; $v2 = static function ( array $data ) use ( &$seen ): array { $seen[] = 'v2:' . $data['comment_content']; $data['comment_content'] = 'v2'; - return $data; }; add_filter( 'internal_comments/create_comment_data', $v1 ); diff --git a/plugins/internal-comments/tests/Integration/DataGuardsTest.php b/plugins/internal-comments/tests/Integration/DataGuardsTest.php index cd05298..8ed2f4b 100644 --- a/plugins/internal-comments/tests/Integration/DataGuardsTest.php +++ b/plugins/internal-comments/tests/Integration/DataGuardsTest.php @@ -2,6 +2,8 @@ namespace DeepWebSolutions\InternalComments\Tests\Integration; +use DeepWebSolutions\InternalComments\Plugin; +use DeepWebSolutions\InternalComments\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; @@ -11,6 +13,8 @@ // Internal comments stay hidden by their status, which WordPress's queries, counts, feeds and REST API leave out without the plugin's help. #[CoversNothing] final class DataGuardsTest extends TestCase { + use RunsRequestsTrait; + protected int $admin; protected int $other; @@ -61,7 +65,6 @@ public function test_a_status_change_leaves_an_internal_comment_internal_and_not $mailed = array(); $record = static function ( mixed $result, array $mail ) use ( &$mailed ): bool { $mailed[] = $mail['subject']; - return true; }; $notify = get_option( 'comments_notify' ); @@ -245,10 +248,10 @@ public function test_wordpress_reads_leave_internal_comments_out_without_the_plu $comment ); - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . ' --skip-plugins=internal-comments eval ' . \escapeshellarg( $php ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- The row needs a request without the plugin. + $request = self::request( $php, '--skip-plugins=' . Plugin::SLUG ); - self::assertSame( 0, $status, \implode( "\n", $output ) ); - self::assertSame( wp_json_encode( array( array( $public ), array( $public ), 1, 1, 401, array( $public ) ) ), (string) \end( $output ) ); + self::assertSame( 0, $request['status'], $request['output'] ); + self::assertSame( wp_json_encode( array( array( $public ), array( $public ), 1, 1, 401, array( $public ) ) ), $request['last'], $request['output'] ); } protected function comment( string $content = 'Internal', array $meta = array() ): int { @@ -273,8 +276,8 @@ public function test_v1s_approved_comments_stay_hidden_until_the_move_finishes() 'comment_type' => DWS_IC_COMMENT_TYPE, 'comment_approved' => '1', 'comment_content' => 'v1', - 'comment_date' => \current_time( 'mysql' ), - 'comment_date_gmt' => \current_time( 'mysql', true ), + 'comment_date' => current_time( 'mysql' ), + 'comment_date_gmt' => current_time( 'mysql', true ), ) ); $v1 = (int) $wpdb->insert_id; diff --git a/plugins/internal-comments/tests/Integration/DeprecatedApiTest.php b/plugins/internal-comments/tests/Integration/DeprecatedApiTest.php index 6343fb3..1e7be77 100644 --- a/plugins/internal-comments/tests/Integration/DeprecatedApiTest.php +++ b/plugins/internal-comments/tests/Integration/DeprecatedApiTest.php @@ -2,15 +2,15 @@ namespace DeepWebSolutions\InternalComments\Tests\Integration; -use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\Before; +use DeepWebSolutions\InternalComments\Tests\Support\RecordsDeprecationsTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversNothing] final class DeprecatedApiTest extends TestCase { - protected array $deprecated = array(); + use RecordsDeprecationsTrait; + public static function hook_tags(): array { return array( @@ -28,26 +28,6 @@ public static function hook_tags(): array { ); } - #[Before] - protected function record_deprecations(): void { - add_filter( 'deprecated_function_trigger_error', '__return_false' ); - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_function_run', array( $this, 'record' ), 10, 2 ); - add_action( 'deprecated_hook_run', array( $this, 'record' ), 10, 2 ); - } - - #[After] - protected function stop_recording(): void { - remove_filter( 'deprecated_function_trigger_error', '__return_false' ); - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_function_run', array( $this, 'record' ) ); - remove_action( 'deprecated_hook_run', array( $this, 'record' ) ); - } - - public function record( string $name, string $replacement ): void { - $this->deprecated[] = array( $name, $replacement ); - } - public function test_the_v1_getters_answer_from_the_plugin_header(): void { $header = get_file_data( DWS_IC_FILE, @@ -98,13 +78,16 @@ public function test_a_v1_hook_tag_builds_as_v1_built_it( string $shim, array $a self::assertSame( array( array( $shim, '' ) ), $this->deprecated ); } - public function test_the_v1_boot_hooks_fire_after_the_v2_ones_with_the_failure(): void { + // The v1 hooks fire at the earliest priority, so every v1 listener runs before a v2 listener of any later one. + public function test_the_v1_boot_hooks_fire_at_the_earliest_priority_with_the_failure(): void { $fired = array(); $spy = static function ( mixed $error = null ) use ( &$fired ): void { $fired[] = array( current_action(), $error ); }; add_action( 'dws_ic_initialized', $spy ); add_action( 'dws_ic_initialization_failure', $spy ); + add_action( 'dws_ic/boot/succeeded', $spy, PHP_INT_MIN + 1 ); + add_action( 'dws_ic/boot/failed', $spy, PHP_INT_MIN + 1 ); $error = new \RuntimeException( 'Failed.' ); try { @@ -113,12 +96,16 @@ public function test_the_v1_boot_hooks_fire_after_the_v2_ones_with_the_failure() } finally { remove_action( 'dws_ic_initialized', $spy ); remove_action( 'dws_ic_initialization_failure', $spy ); + remove_action( 'dws_ic/boot/succeeded', $spy, PHP_INT_MIN + 1 ); + remove_action( 'dws_ic/boot/failed', $spy, PHP_INT_MIN + 1 ); } self::assertSame( array( array( 'dws_ic_initialized', null ), + array( 'dws_ic/boot/succeeded', '' ), array( 'dws_ic_initialization_failure', $error ), + array( 'dws_ic/boot/failed', $error ), ), $fired ); @@ -129,5 +116,7 @@ public function test_the_v1_boot_hooks_fire_after_the_v2_ones_with_the_failure() ), $this->deprecated ); + self::assertSame( PHP_INT_MIN, has_action( 'dws_ic/boot/succeeded', 'dws_ic_fire_v1_initialized' ) ); + self::assertSame( PHP_INT_MIN, has_action( 'dws_ic/boot/failed', 'dws_ic_fire_v1_initialization_failure' ) ); } } diff --git a/plugins/internal-comments/tests/Integration/MetaBoxTest.php b/plugins/internal-comments/tests/Integration/MetaBoxTest.php index de2991e..f27a998 100644 --- a/plugins/internal-comments/tests/Integration/MetaBoxTest.php +++ b/plugins/internal-comments/tests/Integration/MetaBoxTest.php @@ -3,13 +3,16 @@ namespace DeepWebSolutions\InternalComments\Tests\Integration; use DeepWebSolutions\InternalComments\MetaBox; +use DeepWebSolutions\InternalComments\Tests\Support\RecordsDeprecationsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( MetaBox::class )] final class MetaBoxTest extends TestCase { + use RecordsDeprecationsTrait; + protected static array $users = array(); protected int $post; @@ -18,8 +21,6 @@ final class MetaBoxTest extends TestCase { protected mixed $stored; - protected array $deprecated = array(); - // WordPress reports a deprecated hook only when something listens on it. protected const array V1_HOOKS = array( 'internal_comments/metabox_output/comment_classes', @@ -75,36 +76,19 @@ protected function create_posts(): void { ) ); } - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ), 10, 2 ); - foreach ( self::V1_HOOKS as $hook ) { - add_filter( $hook, array( $this, 'listen' ) ); - } + $this->listen_on( ...self::V1_HOOKS ); } #[After] protected function delete_posts(): void { global $wp_meta_boxes; - $wp_meta_boxes = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Each row registers the meta boxes anew. + $wp_meta_boxes = array(); wp_delete_post( $this->post, true ); wp_delete_post( $this->page, true ); update_option( DWS_IC_OPTION_GENERAL, $this->stored ); wp_set_current_user( 0 ); wp_dequeue_script( 'internal-comments-meta-box' ); wp_dequeue_style( 'internal-comments-meta-box' ); - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ) ); - foreach ( self::V1_HOOKS as $hook ) { - remove_filter( $hook, array( $this, 'listen' ) ); - } - } - - public function listen( mixed $value = null ): mixed { - return $value; - } - - public function record_deprecated_hook( string $hook, string $replacement ): void { - $this->deprecated[] = array( $hook, $replacement ); } public function test_the_meta_box_shows_on_a_selected_post_type_to_users_with_the_capability(): void { @@ -146,7 +130,7 @@ public function test_the_markup_hooks_fire_v1s_names_first_then_v2s(): void { $comment = $this->comment( 'Hooked' ); $classes = static fn ( array $classes ): array => \array_merge( $classes, array( 'from-v2' ) ); $action = static function ( \DWS_Internal_Comment $comment ): void { - echo '' . \esc_html( (string) $comment->comment_ID ) . ''; + echo '' . esc_html( (string) $comment->comment_ID ) . ''; }; add_filter( 'dws_ic/meta_box/comment_classes', $classes ); add_action( 'dws_ic/meta_box/before_comment_actions', $action ); @@ -177,11 +161,11 @@ public function test_the_markup_hooks_fire_v1s_names_first_then_v2s(): void { } public function test_the_edit_screen_of_a_selected_post_loads_the_meta_boxs_assets(): void { - $GLOBALS['post'] = get_post( $this->post ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The edit screen sets its post. + $GLOBALS['post'] = get_post( $this->post ); wp_set_current_user( self::$users['administrator'] ); try { - ( new MetaBox() )->enqueue_assets( 'post.php' ); + new MetaBox()->enqueue_assets( 'post.php' ); } finally { unset( $GLOBALS['post'] ); } @@ -192,11 +176,10 @@ public function test_the_edit_screen_of_a_selected_post_loads_the_meta_boxs_asse protected function registers( string $role, int $post ): bool { global $wp_meta_boxes; - $wp_meta_boxes = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Each check registers the meta boxes anew. + $wp_meta_boxes = array(); wp_set_current_user( self::$users[ $role ] ); $type = (string) get_post_type( $post ); do_action( 'add_meta_boxes', $type, get_post( $post ) ); - return isset( $wp_meta_boxes[ $type ]['normal']['core']['dws-internal-comments'] ); } @@ -207,7 +190,6 @@ protected function render( string $role ): \Dom\HTMLDocument { $box = $wp_meta_boxes['post']['normal']['core']['dws-internal-comments']; \ob_start(); \call_user_func( $box['callback'], get_post( $this->post ), $box ); - return \Dom\HTMLDocument::createFromString( '' . \ob_get_clean() . '', \LIBXML_NOERROR ); } diff --git a/plugins/internal-comments/tests/Integration/MetadataTest.php b/plugins/internal-comments/tests/Integration/MetadataTest.php index 657eb10..e97bba9 100644 --- a/plugins/internal-comments/tests/Integration/MetadataTest.php +++ b/plugins/internal-comments/tests/Integration/MetadataTest.php @@ -51,7 +51,6 @@ protected static function get_listed_header( string $header ): string { protected static function translate_name(): \Closure { $name = self::get_listed_header( 'Name' ); - return static fn ( string $translation, string $text ): string => $name === $text ? self::TRANSLATION : $translation; } } diff --git a/plugins/internal-comments/tests/Integration/MigrationsTest.php b/plugins/internal-comments/tests/Integration/MigrationsTest.php index b36f56d..c0b6dc7 100644 --- a/plugins/internal-comments/tests/Integration/MigrationsTest.php +++ b/plugins/internal-comments/tests/Integration/MigrationsTest.php @@ -5,6 +5,7 @@ use DeepWebSolutions\InternalComments\Plugin; use DeepWebSolutions\InternalComments\Scoped\DeepWebSolutions\Framework\Core\Migration\BatchedMigrationStepInterface; use DeepWebSolutions\InternalComments\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; +use DeepWebSolutions\InternalComments\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -12,23 +13,12 @@ #[CoversNothing] final class MigrationsTest extends TestCase { - protected const array ROWS = array( DWS_IC_OPTION_GENERAL, DWS_IC_OPTION_PLUGIN ); + use RestoresOptionsTrait; - protected array $stored = array(); - - #[\Override] - protected function setUp(): void { - foreach ( self::ROWS as $row ) { - $this->stored[ $row ] = get_option( $row ); - } - } + protected const array OPTIONS = array( DWS_IC_OPTION_GENERAL, DWS_IC_OPTION_PLUGIN ); #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $row => $value ) { - false === $value ? delete_option( $row ) : update_option( $row, $value ); - } - + protected function restore_the_revision(): void { update_option( Plugin::PREFIX . '_revision', \count( dws_ic_get_migration_steps() ) ); } @@ -36,7 +26,7 @@ protected function restore_rows(): void { #[RunInSeparateProcess] public function test_the_runner_brings_a_new_site_to_the_latest_revision_with_v1s_defaults_autoloaded(): void { global $wpdb; - foreach ( self::ROWS as $row ) { + foreach ( self::OPTIONS as $row ) { delete_option( $row ); } update_option( Plugin::PREFIX . '_revision', 0 ); @@ -44,7 +34,7 @@ public function test_the_runner_brings_a_new_site_to_the_latest_revision_with_v1 do_action( Plugin::PREFIX . '_revision/migration/run' ); // A batched step runs only from the runner's scheduled hook. self::assertSame( \count( dws_ic_get_migration_steps() ), MigrationRunner::get_revision( Plugin::PREFIX . '_revision' ) ); - foreach ( self::ROWS as $row ) { + foreach ( self::OPTIONS as $row ) { self::assertSame( DWS_IC_SETTING_DEFAULTS[ $row ], get_option( $row ) ); self::assertSame( 'on', $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", $row ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads the stored autoload flag. } @@ -126,11 +116,10 @@ public function test_the_second_step_converts_v1s_internal_comments_in_batches_a 'comment_type' => $type, 'comment_approved' => $status, 'comment_content' => "$type $status", - 'comment_date' => \current_time( 'mysql' ), - 'comment_date_gmt' => \current_time( 'mysql', true ), + 'comment_date' => current_time( 'mysql' ), + 'comment_date_gmt' => current_time( 'mysql', true ), ) ); - return (int) $wpdb->insert_id; }; diff --git a/plugins/internal-comments/tests/Integration/MultisiteTest.php b/plugins/internal-comments/tests/Integration/MultisiteTest.php new file mode 100644 index 0000000..c723344 --- /dev/null +++ b/plugins/internal-comments/tests/Integration/MultisiteTest.php @@ -0,0 +1,41 @@ + get_network()?->domain, + 'path' => get_network()?->path . \str_replace( '_', '-', Plugin::PREFIX ) . '-second/', + ) + ); + self::assertIsInt( $site_id ); + + try { + // WooCommerce queries two of its tables during a new site's first init, before its version check installs them, so a request without the plugin installs WooCommerce first. + $installed = self::request_provoking( 'WordPress database error', 'echo (int) function_exists( "dws_ic_boot" );', '--url=' . get_home_url( $site_id, '/' ), '--skip-plugins=' . Plugin::SLUG ); + self::assertSame( 0, $installed['status'], $installed['output'] ); + self::assertSame( '0', $installed['last'], $installed['output'] ); + + $request = self::request( 'echo did_action( "dws_ic/boot/succeeded" );', '--url=' . get_home_url( $site_id, '/' ) ); + + self::assertSame( '1', $request['last'], $request['output'] ); + self::assertSame( 'no', get_blog_option( $site_id, DWS_IC_OPTION_PLUGIN )[ DWS_IC_SETTING_DELETE_DATA ] ?? null ); + self::assertTrue( update_blog_option( $site_id, DWS_IC_OPTION_PLUGIN, array( DWS_IC_SETTING_DELETE_DATA => 'yes' ) ) ); + self::assertSame( 'no', get_option( DWS_IC_OPTION_PLUGIN )[ DWS_IC_SETTING_DELETE_DATA ] ?? null ); + } finally { + wp_delete_site( $site_id ); + } + } +} diff --git a/plugins/internal-comments/tests/Integration/OrdersTest.php b/plugins/internal-comments/tests/Integration/OrdersTest.php index 8a110f1..b496ff6 100644 --- a/plugins/internal-comments/tests/Integration/OrdersTest.php +++ b/plugins/internal-comments/tests/Integration/OrdersTest.php @@ -5,6 +5,7 @@ use Automattic\WooCommerce\Utilities\FeaturesUtil; use DeepWebSolutions\InternalComments\AdminScreens; use DeepWebSolutions\InternalComments\MetaBox; +use DeepWebSolutions\InternalComments\Tests\Support\OrderStorageTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; @@ -14,7 +15,7 @@ // Orders live in WooCommerce's order tables, in the posts table, or in both, so each row runs on every storage. #[CoversNothing] final class OrdersTest extends TestCase { - use OrderStorage; + use OrderStorageTrait; protected static array $users = array(); @@ -54,7 +55,7 @@ protected function select_orders(): void { #[After] protected function restore_settings(): void { global $wp_meta_boxes; - $wp_meta_boxes = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Each row registers the meta boxes anew. + $wp_meta_boxes = array(); update_option( DWS_IC_OPTION_GENERAL, $this->stored ); wp_set_current_user( 0 ); } @@ -86,7 +87,7 @@ public function test_the_order_screen_shows_the_meta_box( bool $hpos, bool $sync dws_ic_create_internal_comment( array( 'comment_post_ID' => $order->get_id(), 'comment_content' => 'On the order' ), get_user_by( 'id', self::$users['administrator'] ) ?: null ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound -- A comment's two fields. wp_set_current_user( self::$users['shop_manager'] ); - ( new MetaBox() )->add_meta_box( $screen, $object ); + new MetaBox()->add_meta_box( $screen, $object ); $box = $wp_meta_boxes[ $screen ]['normal']['core'][ MetaBox::ID ] ?? null; self::assertIsArray( $box ); \ob_start(); @@ -119,7 +120,6 @@ protected function post_comment( string $role, int $order_id, string $content ): wp_set_current_user( self::$users[ $role ] ); $request = new \WP_REST_Request( 'POST', "/dws_ic/v1/posts/$order_id/comments" ); $request->set_param( 'content', $content ); - return rest_do_request( $request )->get_status(); } } diff --git a/plugins/internal-comments/tests/Integration/PartialDeployTest.php b/plugins/internal-comments/tests/Integration/PartialDeployTest.php index ec28df5..b749e75 100644 --- a/plugins/internal-comments/tests/Integration/PartialDeployTest.php +++ b/plugins/internal-comments/tests/Integration/PartialDeployTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\InternalComments\Tests\Integration; +use DeepWebSolutions\InternalComments\Plugin; use DeepWebSolutions\InternalComments\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use DeepWebSolutions\InternalComments\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\CoversNothing; @@ -29,7 +30,7 @@ public function test_an_include_that_throws_shows_the_build_notice_and_boots_not $include = \dirname( DWS_IC_FILE ) . '/includes/zz-throws.php'; \file_put_contents( $include, " 'yes' ) ); $log = (string) \tempnam( \sys_get_temp_dir(), 'dws-tpl-uninstall-' ); - \ini_set( 'error_log', $log ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- The child process's stderr would fail the test. + \ini_set( 'error_log', $log ); \spl_autoload_register( static function ( string $class_name ): void { if ( Uninstaller::class === $class_name ) { @@ -86,4 +87,20 @@ static function ( string $class_name ): void { \unlink( $log ); update_option( DWS_IC_OPTION_PLUGIN, DWS_IC_SETTING_DEFAULTS[ DWS_IC_OPTION_PLUGIN ] ); } + + public function test_a_build_error_is_logged_with_its_location_once_an_hour(): void { + $message = 'A partial deploy ' . \uniqid() . '.'; + $include = \dirname( DWS_IC_FILE ) . '/includes/zz-throws.php'; + \file_put_contents( $include, "posts[] = $post; - return $post; } @@ -261,7 +261,7 @@ public function test_core_replies_post_an_internal_comment_only_for_users_who_ma $post = $this->post( 'administrator' ); $stop = static function (): callable { return static function ( mixed $message ): void { - throw new \RuntimeException( 'Refused: ' . \print_r( $message, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- Names the refusal. + throw new \RuntimeException( 'Refused: ' . \print_r( $message, true ) ); }; }; $refuse = function ( string $type ) use ( $post ): bool { @@ -296,7 +296,7 @@ public function test_core_replies_post_an_internal_comment_only_for_users_who_ma $with = $refuse( DWS_IC_COMMENT_TYPE ); } finally { get_role( 'editor' )?->add_cap( DWS_IC_CAP_INSERT ); - $GLOBALS['wp_filter']['wp_ajax_replyto-comment'] = $hooked; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Puts WooCommerce's handler back. + $GLOBALS['wp_filter']['wp_ajax_replyto-comment'] = $hooked; remove_filter( 'wp_doing_ajax', '__return_true' ); remove_filter( 'wp_die_ajax_handler', $stop ); } diff --git a/plugins/internal-comments/tests/Integration/PluginBootTest.php b/plugins/internal-comments/tests/Integration/PluginBootTest.php index 44d8f0c..0774b96 100644 --- a/plugins/internal-comments/tests/Integration/PluginBootTest.php +++ b/plugins/internal-comments/tests/Integration/PluginBootTest.php @@ -15,7 +15,7 @@ final class PluginBootTest extends TestCase { public function test_the_plugin_boots_every_component(): void { $plugin = dws_ic_plugin(); - self::assertSame( 'booted', $plugin->boot->status ); + self::assertSame( 'booted', $plugin->boundary->status ); self::assertSame( array(), $plugin->kernel->skipped ); } @@ -34,6 +34,19 @@ public function test_the_entry_loads_every_include_but_those_whose_names_start_w self::assertSame( 'skipped', $request['last'], $request['output'] ); } + public function test_a_boot_listener_that_throws_is_not_reported_as_an_incomplete_build(): void { + $include = \dirname( DWS_IC_FILE ) . '/includes/zz-boot-listener.php'; + \file_put_contents( $include, "listen_on( ...self::V1_HOOKS ); } #[After] @@ -82,19 +82,10 @@ protected function delete_posts(): void { wp_delete_post( $this->page, true ); update_option( DWS_IC_OPTION_GENERAL, $this->stored ); wp_set_current_user( 0 ); - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ) ); - foreach ( self::V1_HOOKS as $hook ) { - remove_filter( $hook, array( $this, 'listen' ) ); - } - } - - public function listen( mixed $value = null ): mixed { - return $value; } - public function record_deprecated_hook( string $hook ): void { - $this->seen[] = "deprecated:$hook"; + public function record_deprecation( string $name, string $replacement ): void { + $this->seen[] = "deprecated:$name"; } public function test_posting_a_comment_stores_its_text_and_answers_with_the_list(): void { diff --git a/plugins/internal-comments/tests/Integration/SettingsApiTest.php b/plugins/internal-comments/tests/Integration/SettingsApiTest.php index fdb3595..4f63a73 100644 --- a/plugins/internal-comments/tests/Integration/SettingsApiTest.php +++ b/plugins/internal-comments/tests/Integration/SettingsApiTest.php @@ -2,19 +2,18 @@ namespace DeepWebSolutions\InternalComments\Tests\Integration; -use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\Before; +use DeepWebSolutions\InternalComments\Tests\Support\RecordsDeprecationsTrait; +use DeepWebSolutions\InternalComments\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversNothing] final class SettingsApiTest extends TestCase { - protected const array ROWS = array( DWS_IC_OPTION_GENERAL, DWS_IC_OPTION_PLUGIN ); + use RecordsDeprecationsTrait; + use RestoresOptionsTrait; - protected array $stored = array(); - - protected array $deprecated = array(); + protected const array OPTIONS = array( DWS_IC_OPTION_GENERAL, DWS_IC_OPTION_PLUGIN ); public static function stored_settings(): array { return array( @@ -30,28 +29,6 @@ public static function stored_settings(): array { ); } - #[Before] - protected function store_rows(): void { - foreach ( self::ROWS as $row ) { - $this->stored[ $row ] = get_option( $row ); - } - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ), 10, 2 ); - } - - #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $row => $value ) { - false === $value ? delete_option( $row ) : update_option( $row, $value ); - } - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ) ); - } - - public function record_deprecated_hook( string $hook, string $replacement ): void { - $this->deprecated[] = array( $hook, $replacement ); - } - #[DataProvider( 'stored_settings' )] public function test_a_setting_reads_from_its_v1_row_validated_by_its_field( string $row, string $field, mixed $stored, string $field_id, ?string $group, array|bool $expected ): void { update_option( $row, array( $field => $stored ) ); @@ -61,7 +38,7 @@ public function test_a_setting_reads_from_its_v1_row_validated_by_its_field( str } public function test_missing_settings_read_as_v1_defaults(): void { - foreach ( self::ROWS as $row ) { + foreach ( self::OPTIONS as $row ) { delete_option( $row ); } @@ -134,18 +111,15 @@ public function test_a_validated_value_passes_the_v1_filters_of_its_path_then_th $filters = array( 'internal_comments/general_settings/get_validated_option_value' => static function ( mixed $value, string $field_id ) use ( &$seen ): mixed { $seen[] = array( 'group', $value, $field_id ); - return 'group'; }, 'internal_comments/settings/get_validated_option_value' => static function ( mixed $value, string $field_id ) use ( &$seen ): mixed { $seen[] = array( 'section', $value, $field_id ); - return 'section'; }, ); $v2 = static function ( mixed $value, string $field_id, string $group ) use ( &$seen ): mixed { $seen[] = array( 'v2', $value, $field_id, $group ); - return 'v2'; }; foreach ( $filters as $hook => $filter ) { diff --git a/plugins/internal-comments/tests/Integration/SettingsTest.php b/plugins/internal-comments/tests/Integration/SettingsTest.php index 772d65a..07ae2f4 100644 --- a/plugins/internal-comments/tests/Integration/SettingsTest.php +++ b/plugins/internal-comments/tests/Integration/SettingsTest.php @@ -4,21 +4,22 @@ use DeepWebSolutions\InternalComments\Scoped\DeepWebSolutions\Framework\Settings\AdminPage\OptionsPage; use DeepWebSolutions\InternalComments\Settings; +use DeepWebSolutions\InternalComments\Tests\Support\RecordsDeprecationsTrait; +use DeepWebSolutions\InternalComments\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( Settings::class )] final class SettingsTest extends TestCase { - protected const array ROWS = array( DWS_IC_OPTION_GENERAL, DWS_IC_OPTION_PLUGIN ); + use RecordsDeprecationsTrait; + use RestoresOptionsTrait; - protected static OptionsPage $page; - - protected array $stored = array(); + protected const array OPTIONS = array( DWS_IC_OPTION_GENERAL, DWS_IC_OPTION_PLUGIN ); - protected array $deprecated = array(); + protected static OptionsPage $page; #[\Override] public static function setUpBeforeClass(): void { @@ -29,7 +30,7 @@ public static function setUpBeforeClass(): void { #[\Override] public static function tearDownAfterClass(): void { - foreach ( self::ROWS as $row ) { + foreach ( self::OPTIONS as $row ) { remove_filter( "pre_update_option_$row", array( self::$page, 'sanitize_submission' ) ); remove_filter( "option_page_capability_$row", array( self::$page, 'get_capability' ) ); unregister_setting( $row, $row ); @@ -44,31 +45,17 @@ public static function setting_groups(): array { } #[Before] - protected function store_rows(): void { - foreach ( self::ROWS as $row ) { - $this->stored[ $row ] = get_option( $row ); - } + protected function forget_the_settings_errors(): void { $GLOBALS['wp_settings_errors'] = array(); - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ), 10, 2 ); } #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $row => $value ) { - false === $value ? delete_option( $row ) : update_option( $row, $value ); - } + protected function forget_the_tab(): void { unset( $_GET['tab'] ); - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ) ); - } - - public function record_deprecated_hook( string $hook, string $replacement ): void { - $this->deprecated[] = array( $hook, $replacement ); } public function test_the_page_keeps_v1s_slug_parent_capability_and_tabs(): void { - $page = ( new Settings() )->get_options_page( Settings::PAGE ); + $page = new Settings()->get_options_page( Settings::PAGE ); self::assertSame( 'dws-internal-comments', Settings::PAGE ); self::assertSame( 'options-general.php', $page['parent'] ); @@ -151,7 +138,6 @@ public function test_a_groups_fields_pass_its_v1_filter_first_then_its_v2_filter $v2_hook = "dws_ic/settings/{$group}_fields"; $v1_extra = static function ( array $fields ) use ( &$seen ): array { $seen[] = array( 'v1', \array_keys( $fields ) ); - return $fields + array( 'extra' => array( 'type' => 'text', @@ -161,14 +147,13 @@ public function test_a_groups_fields_pass_its_v1_filter_first_then_its_v2_filter }; $v2_spy = static function ( array $fields ) use ( &$seen ): array { $seen[] = array( 'v2', \array_column( $fields, 'id' ) ); - return $fields; }; add_filter( $v1_hook, $v1_extra ); add_filter( $v2_hook, $v2_spy ); try { - $tabs = ( new Settings() )->get_options_page( Settings::PAGE )['tabs']; + $tabs = new Settings()->get_options_page( Settings::PAGE )['tabs']; } finally { remove_filter( $v1_hook, $v1_extra ); remove_filter( $v2_hook, $v2_spy ); @@ -186,7 +171,7 @@ public function test_the_plugin_row_links_to_the_page_and_to_the_support_forum() $links = apply_filters( "plugin_action_links_{$basename}", array( 'deactivate' => 'Deactivate' ), $basename, array(), 'all' ); $meta = apply_filters( 'plugin_row_meta', array(), $basename, array(), 'all' ); - self::assertStringContainsString( \esc_url( admin_url( 'options-general.php?page=dws-internal-comments' ) ), (string) \reset( $links ) ); + self::assertStringContainsString( esc_url( admin_url( 'options-general.php?page=dws-internal-comments' ) ), (string) \reset( $links ) ); self::assertStringContainsString( 'https://wordpress.org/support/plugin/internal-comments/', \implode( '', $meta ) ); self::assertSame( array(), apply_filters( 'plugin_row_meta', array(), 'another-plugin/another-plugin.php', array(), 'all' ) ); } @@ -197,7 +182,6 @@ protected function render( string $tab ): \Dom\HTMLDocument { $_GET['tab'] = $tab; \ob_start(); self::$page->render_page(); - return \Dom\HTMLDocument::createFromString( '' . \ob_get_clean() . '', \LIBXML_NOERROR ); } diff --git a/plugins/internal-comments/tests/Integration/UninstallTest.php b/plugins/internal-comments/tests/Integration/UninstallTest.php index b74ba57..d067741 100644 --- a/plugins/internal-comments/tests/Integration/UninstallTest.php +++ b/plugins/internal-comments/tests/Integration/UninstallTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\InternalComments\Tests\Integration; use DeepWebSolutions\InternalComments\Plugin; +use DeepWebSolutions\InternalComments\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\AfterClass; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -10,6 +11,8 @@ #[CoversNothing] final class UninstallTest extends TestCase { + use RunsRequestsTrait; + protected const int USER = 1; #[AfterClass] @@ -23,7 +26,7 @@ public function test_an_uninstall_checks_the_floors_of_its_own_main_file(): void $source = (string) \file_get_contents( DWS_IC_FILE ); \file_put_contents( DWS_IC_FILE, (string) \preg_replace( '/^(\s*\*\s*Requires PHP:\s*).*$/m', '${1}99.0', $source ) ); $log = (string) \tempnam( \sys_get_temp_dir(), 'dws-ic-uninstall-' ); - \ini_set( 'error_log', $log ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- The child process's stderr would fail the test. + \ini_set( 'error_log', $log ); try { include \dirname( DWS_IC_FILE ) . '/uninstall.php'; @@ -40,20 +43,49 @@ public function test_an_uninstall_in_a_request_that_skips_the_plugin_removes_its update_option( DWS_IC_OPTION_PLUGIN, array( DWS_IC_SETTING_DELETE_DATA => 'yes' ) ); $php = 'uninstall_plugin( "internal-comments/internal-comments.php" ); echo wp_json_encode( array( defined( "DWS_IC_FILE" ), get_option( "' . DWS_IC_OPTION_PLUGIN . '" ) ) );'; - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . ' --skip-plugins=internal-comments eval ' . \escapeshellarg( $php ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- The uninstall needs a request without the plugin. + $request = self::request( $php, '--skip-plugins=' . Plugin::SLUG ); // The other tests share the database, so a request with the plugin stores again what a new site starts with, as the site's next request would. - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . ' eval ' . \escapeshellarg( 'do_action( "' . Plugin::PREFIX . '_revision/migration/run" );' ) . ' 2>&1', $restored, $restore_status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- The restore needs a request with the plugin. + $restored = self::request( 'do_action( "' . Plugin::PREFIX . '_revision/migration/run" );' ); wp_cache_flush(); wp_roles()->for_site(); - self::assertSame( 0, $status, \implode( "\n", $output ) ); - self::assertSame( '[false,false]', (string) \end( $output ) ); - self::assertSame( 0, $restore_status, \implode( "\n", $restored ) ); + self::assertSame( 0, $request['status'], $request['output'] ); + self::assertSame( '[false,false]', $request['last'], $request['output'] ); + self::assertSame( 0, $restored['status'], $restored['output'] ); self::assertSame( DWS_IC_SETTING_DEFAULTS[ DWS_IC_OPTION_PLUGIN ], get_option( DWS_IC_OPTION_PLUGIN ) ); self::assertTrue( get_role( 'editor' )?->has_cap( DWS_IC_CAP_INSERT ) ); } + #[RunInSeparateProcess] + public function test_a_web_uninstall_whose_engine_fails_ends_with_its_message_and_keeps_the_consent(): void { + global $wpdb; + + \define( 'WP_UNINSTALL_PLUGIN', plugin_basename( DWS_IC_FILE ) ); + update_option( DWS_IC_OPTION_PLUGIN, array( DWS_IC_SETTING_DELETE_DATA => 'yes' ) ); + $failing = "DELETE FROM `$wpdb->options` WHERE `option_name` = '" . Plugin::PREFIX . "_revision'"; + add_filter( 'query', static fn ( string $query ): string => \str_contains( $query, $failing ) ? 'SELECT * FROM dws_ic_missing_table' : $query ); + add_filter( 'wp_die_handler', static fn (): \Closure => static fn ( string $message, string $title, array $args ): never => throw new \LogicException( $args['response'] . ' ' . $message ) ); + $wpdb->suppress_errors(); + + try { + include \dirname( DWS_IC_FILE ) . '/uninstall.php'; + self::fail( 'The uninstall finished.' ); + } catch ( \LogicException $died ) { + self::assertStringStartsWith( '500 Uninstall could not remove everything: option ' . Plugin::PREFIX . '_revision', $died->getMessage() ); + self::assertSame( 'yes', get_option( DWS_IC_OPTION_PLUGIN )[ DWS_IC_SETTING_DELETE_DATA ] ?? null ); + } finally { + remove_all_filters( 'query' ); + delete_option( DWS_IC_OPTION_PLUGIN ); + delete_option( Plugin::PREFIX . '_revision' ); + wp_cache_flush(); + wp_roles()->for_site(); + // The other tests share the database, so the runner stores again what a new site starts with, and the grants act again. + do_action( Plugin::PREFIX . '_revision/migration/run' ); // A batched migration step runs only from the runner's scheduled hook. + do_action( 'wp_loaded' ); + } + } + #[RunInSeparateProcess] public function test_an_uninstall_after_v1_consent_removes_the_internal_comments_v1s_rows_and_the_capabilities(): void { update_option( DWS_IC_OPTION_GENERAL, array( DWS_IC_SETTING_POST_TYPES => array( 'post' ) ) ); diff --git a/plugins/internal-comments/tests/Integration/XmlRpcTest.php b/plugins/internal-comments/tests/Integration/XmlRpcTest.php index 805b965..0310296 100644 --- a/plugins/internal-comments/tests/Integration/XmlRpcTest.php +++ b/plugins/internal-comments/tests/Integration/XmlRpcTest.php @@ -48,7 +48,7 @@ public function test_xml_rpc_lists_no_internal_comments(): void { add_filter( 'xmlrpc_enabled', '__return_true' ); try { - $comments = ( new \wp_xmlrpc_server() )->wp_getComments( + $comments = new \wp_xmlrpc_server()->wp_getComments( array( 1, 'ic-xmlrpc-editor', diff --git a/plugins/internal-comments/tests/Integration/bootstrap.php b/plugins/internal-comments/tests/Integration/bootstrap.php index 287bb94..990f858 100644 --- a/plugins/internal-comments/tests/Integration/bootstrap.php +++ b/plugins/internal-comments/tests/Integration/bootstrap.php @@ -1,5 +1,21 @@ , as `local`, where the framework rethrows boot and migration failures. - `.wp-env.tests.json` is the tests site on , as `production`, which the Integration and end-to-end suites share. A row whose separate process changes the database flushes the parent's object cache, and the end-to-end specs set their own preconditions, because either suite may run first. +- `.wp-env.multisite.json` is a network on , as `production`, with WooCommerce and the plugin network-activated, where the Integration suite's `multisite` group runs and the default run skips it. -Both start WordPress on PHP 8.5 with WooCommerce from `vendor/` and its order tables on. +Each starts WordPress on PHP 8.5 with WooCommerce from `vendor/` and its order tables on. ## Running @@ -33,6 +34,12 @@ Start the tests site, build the scripts and run the Integration suite: composer test:integration ``` +Start the multisite site, build the scripts and run the Integration suite's `multisite` group: + +```sh +composer test:integration:multisite +``` + Run the end-to-end suite, which starts the tests site when it is down and needs the built scripts and, once per machine, Playwright's Chromium: ```sh diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/OrderStorage.php b/plugins/internal-comments/tests/Support/OrderStorageTrait.php similarity index 82% rename from plugins/linked-orders-for-woocommerce/tests/Integration/OrderStorage.php rename to plugins/internal-comments/tests/Support/OrderStorageTrait.php index b64ad7a..c48e732 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/OrderStorage.php +++ b/plugins/internal-comments/tests/Support/OrderStorageTrait.php @@ -1,11 +1,11 @@ storage_filters = array( 'pre_option_woocommerce_custom_orders_table_enabled' => static fn (): string => $hpos ? 'yes' : 'no', 'pre_option_woocommerce_custom_orders_table_data_sync_enabled' => static fn (): string => $sync ? 'yes' : 'no', - 'pre_option_woocommerce_hpos_datastore_caching_enabled' => static fn (): string => $caching ? 'yes' : 'no', + 'pre_option_woocommerce_hpos_datastore_caching_enabled' => static fn (): string => 'no', ); foreach ( $this->storage_filters as $hook => $filter ) { add_filter( $hook, $filter ); @@ -34,7 +34,6 @@ protected function create_order( array $args = array() ): \WC_Order { $order = wc_create_order( $args + array( 'status' => 'pending' ) ); self::assertInstanceOf( \WC_Order::class, $order ); $this->orders[] = $order->get_id(); - return $order; } @@ -47,11 +46,13 @@ protected function delete_orders_and_restore_storage(): void { $order->delete( true ); } } + $this->orders = array(); foreach ( $this->storage_filters as $hook => $filter ) { remove_filter( $hook, $filter ); } + $this->storage_filters = array(); } } diff --git a/plugins/internal-comments/tests/Support/RecordsDeprecationsTrait.php b/plugins/internal-comments/tests/Support/RecordsDeprecationsTrait.php new file mode 100644 index 0000000..3764ad8 --- /dev/null +++ b/plugins/internal-comments/tests/Support/RecordsDeprecationsTrait.php @@ -0,0 +1,48 @@ +listened as $hook ) { + remove_filter( $hook, array( $this, 'pass_through' ) ); + } + + remove_action( 'deprecated_hook_run', array( $this, 'record_deprecation' ) ); + remove_action( 'deprecated_function_run', array( $this, 'record_deprecation' ) ); + remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); + remove_filter( 'deprecated_function_trigger_error', '__return_false' ); + } + + public function record_deprecation( string $name, string $replacement ): void { + $this->deprecated[] = array( $name, $replacement ); + } + + // WordPress reports a deprecated hook only when something listens on it. + protected function listen_on( string ...$hooks ): void { + foreach ( $hooks as $hook ) { + add_filter( $hook, array( $this, 'pass_through' ) ); + $this->listened[] = $hook; + } + } + + public function pass_through( mixed $value = null ): mixed { + return $value; + } +} diff --git a/plugins/internal-comments/tests/Support/RestoresOptionsTrait.php b/plugins/internal-comments/tests/Support/RestoresOptionsTrait.php new file mode 100644 index 0000000..225eef4 --- /dev/null +++ b/plugins/internal-comments/tests/Support/RestoresOptionsTrait.php @@ -0,0 +1,24 @@ +stored_options[ $key ] = get_option( $key ); + } + } + + #[After] + protected function restore_options(): void { + foreach ( $this->stored_options as $key => $value ) { + false === $value ? delete_option( $key ) : update_option( $key, $value ); + } + } +} diff --git a/plugins/internal-comments/tests/Support/RunsRequestsTrait.php b/plugins/internal-comments/tests/Support/RunsRequestsTrait.php index db63826..17a68af 100644 --- a/plugins/internal-comments/tests/Support/RunsRequestsTrait.php +++ b/plugins/internal-comments/tests/Support/RunsRequestsTrait.php @@ -3,13 +3,18 @@ namespace DeepWebSolutions\InternalComments\Tests\Support; trait RunsRequestsTrait { - /** - * @return array{status: int, output: string, last: string} - */ - protected static function request( string $php ): array { - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . ' eval ' . \escapeshellarg( $php ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Each row needs a fresh request that loads the plugin. - $output = \array_values( \array_filter( $output, static fn ( string $line ): bool => '' !== \trim( $line ) ) ); + protected static function request( string $php, string ...$flags ): array { + return self::request_provoking( '', $php, ...$flags ); + } + + // A row whose fixture provokes a diagnostic names it, so that diagnostic and what follows it, such as a fatal error's trace, pass, while any output before it fails the row. + protected static function request_provoking( string $diagnostic, string $php, string ...$flags ): array { + $arguments = \implode( '', \array_map( static fn ( string $flag ): string => ' ' . \escapeshellarg( $flag ), $flags ) ); + \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . "$arguments eval " . \escapeshellarg( $php ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Each row needs a fresh request that loads the plugin. + $output = \array_values( \array_filter( $output, static fn ( string $line ): bool => '' !== \trim( $line ) ) ); + $provoked = '' === $diagnostic ? null : \array_find_key( $output, static fn ( string $line ): bool => \str_contains( $line, $diagnostic ) ); + self::assertSame( array(), \array_slice( $output, 0, \is_int( $provoked ) ? $provoked : \max( 0, \count( $output ) - 1 ) ), \implode( "\n", $output ) ); return array( 'status' => $status, 'output' => \implode( "\n", $output ), diff --git a/plugins/internal-comments/tests/Unit/DirectoryStubsTest.php b/plugins/internal-comments/tests/Unit/DirectoryStubsTest.php new file mode 100644 index 0000000..7ad8dc6 --- /dev/null +++ b/plugins/internal-comments/tests/Unit/DirectoryStubsTest.php @@ -0,0 +1,33 @@ + $file->isDir() && ! \str_starts_with( $file->getFilename(), '.' ) && ! \in_array( $file->getFilename(), self::SKIPPED, true ) + ), + \RecursiveIteratorIterator::SELF_FIRST + ); + $missing = array(); + foreach ( array( $root, ...\array_keys( \iterator_to_array( $directories ) ) ) as $directory ) { + $stub = "$directory/index.php"; + if ( ! \is_file( $stub ) || " 'wp-plugin/woocommerce' === $package['name'] ); + $entry = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/' . Plugin::SLUG . '.php' ); + + self::assertIsArray( $woocommerce ); + self::assertSame( 1, \preg_match( '/^\s*\*\s*WC tested up to:\s*(\S+)\s*$/m', $entry, $header ) ); + self::assertSame( \implode( '.', \array_slice( \explode( '.', $woocommerce['version'] ), 0, 2 ) ), $header[1] ); + } +} diff --git a/plugins/internal-comments/tests/Unit/FootprintTest.php b/plugins/internal-comments/tests/Unit/FootprintTest.php index ea5af91..b1fd601 100644 --- a/plugins/internal-comments/tests/Unit/FootprintTest.php +++ b/plugins/internal-comments/tests/Unit/FootprintTest.php @@ -17,7 +17,7 @@ public function test_the_prefix_has_one_segment_after_dws_so_no_prefix_in_its_fa } public function test_every_kind_is_one_the_uninstall_engine_removes(): void { - $doc = (string) ( new \ReflectionClass( Uninstaller::class ) )->getDocComment(); + $doc = (string) new \ReflectionClass( Uninstaller::class )->getDocComment(); self::assertSame( 1, \preg_match( '/@phpstan-type Footprint (.+)$/m', $doc, $type ) ); \preg_match_all( '/(\w+)\?:/', $type[1], $kinds ); diff --git a/plugins/internal-comments/tests/Unit/ReadmeTest.php b/plugins/internal-comments/tests/Unit/ReadmeTest.php index ad3c00d..682ef08 100644 --- a/plugins/internal-comments/tests/Unit/ReadmeTest.php +++ b/plugins/internal-comments/tests/Unit/ReadmeTest.php @@ -16,6 +16,12 @@ public function test_the_readme_repeats_the_entry_files_version_and_floors(): vo self::assertSame( $entry['Requires PHP'], $readme['Requires PHP'] ); } + // Plugin Check refuses a Tested up to in the entry, in any case, as WordPress reads headers, so the readme holds the only one. + public function test_the_entry_leaves_tested_up_to_to_the_readme(): void { + self::assertDoesNotMatchRegularExpression( '/^(?:[ \t]*<\?(?:php)?)?[ \t\/*#@]*Tested up to:/mi', (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/internal-comments.php' ) ); + self::assertArrayHasKey( 'Tested up to', self::read_fields( 'readme.txt' ) ); + } + public function test_the_readme_holds_the_changelog_markers_the_sync_fills(): void { $readme = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/readme.txt' ); @@ -24,7 +30,6 @@ public function test_the_readme_holds_the_changelog_markers_the_sync_fills(): vo protected static function read_fields( string $file ): array { \preg_match_all( '/^[\s*]*([A-Za-z ]+):\s*(\S.*?)\s*$/m', (string) \file_get_contents( \dirname( __DIR__, 2 ) . "/$file" ), $matches ); - return \array_combine( $matches[1], $matches[2] ); } } diff --git a/plugins/internal-comments/uninstall.php b/plugins/internal-comments/uninstall.php index 30f8e57..ddb0487 100644 --- a/plugins/internal-comments/uninstall.php +++ b/plugins/internal-comments/uninstall.php @@ -30,10 +30,6 @@ require_once __DIR__ . '/includes/footprint.php'; require_once __DIR__ . '/includes/options.php'; $dws_ic_footprint = dws_ic_get_footprint(); - $dws_ic_consent = array( - 'option' => DWS_IC_OPTION_PLUGIN, - 'key' => DWS_IC_SETTING_DELETE_DATA, - ); if ( ! class_exists( Uninstaller::class ) ) { throw new RuntimeException( 'its uninstall engine is missing.' ); } @@ -47,8 +43,22 @@ return; } -Uninstaller::run( - $dws_ic_file, - $dws_ic_footprint, - array( 'consent' => $dws_ic_consent ) -); +try { + Uninstaller::run( + $dws_ic_file, + $dws_ic_footprint, + array( + 'consent' => array( + 'option' => DWS_IC_OPTION_PLUGIN, + 'key' => DWS_IC_SETTING_DELETE_DATA, + ), + ) + ); +} catch ( RuntimeException $exception ) { + if ( defined( 'WP_CLI' ) && true === WP_CLI ) { + throw $exception; + } + + // wp_die() ends the request before WordPress deletes the files, so the next uninstall retries, and shows the reason where a throw shows only the critical-error page. + wp_die( esc_html( $exception->getMessage() ), '', array( 'response' => 500 ) ); +} diff --git a/plugins/linked-orders-for-woocommerce/.distignore b/plugins/linked-orders-for-woocommerce/.distignore index 5faf091..8aa76ac 100644 --- a/plugins/linked-orders-for-woocommerce/.distignore +++ b/plugins/linked-orders-for-woocommerce/.distignore @@ -1,40 +1,52 @@ -.cache -.distignore -.editorconfig +# Baseline wp dist-archive exclusions for WordPress projects. + .git .github -.gitignore .idea .vscode -.wp-env.json -.wp-env.override.json -.wp-env.tests.json - +.editorconfig +.gitattributes +.gitignore +.distignore AGENTS.md -CHANGELOG.md CLAUDE.md CONTRIBUTING.md -README.md SECURITY.md - -changelog/ -docs/ - -node_modules/ -tests/ -vendor/bin/ - +/changelog +node_modules +tests +vendor/bin composer.json composer.lock package.json package-lock.json +.phpcs*.xml* +phpcs*.xml* +.phpstan*.neon* +phpstan*.neon* +phpunit*.xml* +.phpunit.result.cache +.phpunit.cache +infection.json* +eslint.config.* +stylelint.config.* tsconfig.json -eslint.config.mjs -stylelint.config.js -phpcs.dist.xml -phpcs.tests.dist.xml -phpstan.dist.neon -phpstan.safeguards.neon -phpunit.dist.xml -playwright.config.js +playwright.config.* +postcss.config.* scoper.inc.php +.wp-env* +.wordpress-org + +# Secret material, with .pem anchored to the root so certificates bundled under vendor/ still ship. +.env* +/*.pem +*.key +id_rsa* +.npmrc +auth.json + +# The plugin's own exclusions, beyond configs' baseline above. +.cache +CHANGELOG.md +README.md +docs/ diff --git a/plugins/linked-orders-for-woocommerce/.github/workflows/audit.yml b/plugins/linked-orders-for-woocommerce/.github/workflows/audit.yml index ff6c180..345f398 100644 --- a/plugins/linked-orders-for-woocommerce/.github/workflows/audit.yml +++ b/plugins/linked-orders-for-woocommerce/.github/workflows/audit.yml @@ -5,17 +5,18 @@ on: branches: [trunk] pull_request: schedule: - # Weekly Monday at 07:00 UTC — staggered after dependabot (06:00) so PRs from - # dependency bumps surface audit results in the same morning batch. + # Weekly, so advisories published against an unchanged lock surface without a push. - cron: '0 7 * * 1' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read jobs: audit: name: Audit - # npm gate audits production deps only — the dev-tooling graph (wp-scripts/playwright trees) is advisory via dependabot alerts. + # Only production npm packages gate the audit, since a release ships none of the development tooling. uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@b107001cce9f2960495d9d50481c8540d228f60c - with: - npm-audit-flags: '--omit=dev --audit-level=high' diff --git a/plugins/linked-orders-for-woocommerce/.github/workflows/codeql.yml b/plugins/linked-orders-for-woocommerce/.github/workflows/codeql.yml index 1e142c0..cd74e65 100644 --- a/plugins/linked-orders-for-woocommerce/.github/workflows/codeql.yml +++ b/plugins/linked-orders-for-woocommerce/.github/workflows/codeql.yml @@ -16,8 +16,11 @@ permissions: {} jobs: analyze: + name: CodeQL permissions: actions: read contents: read security-events: write uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@b107001cce9f2960495d9d50481c8540d228f60c + with: + languages: '["actions", "javascript-typescript"]' diff --git a/plugins/linked-orders-for-woocommerce/.github/workflows/quality.yml b/plugins/linked-orders-for-woocommerce/.github/workflows/quality.yml index d362366..6263e15 100644 --- a/plugins/linked-orders-for-woocommerce/.github/workflows/quality.yml +++ b/plugins/linked-orders-for-woocommerce/.github/workflows/quality.yml @@ -5,19 +5,23 @@ on: branches: [trunk] pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read jobs: lint-php: - name: PHP Lint + name: Lint PHP uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@b107001cce9f2960495d9d50481c8540d228f60c with: php-version: '8.5' - scripts: '["lint:php:phpcs", "lint:php:phpcs:tests", "lint:php:phpstan"]' + scripts: '["lint:php:phpcs", "lint:php:phpcs:tests", "lint:php:phpstan", "changelog:validate"]' lint-scripts-styles: - name: Scripts and styles lint + name: Lint scripts and styles uses: ahegyes/wordpress-configs/.github/workflows/reusable-scripts-styles-lint.yml@b107001cce9f2960495d9d50481c8540d228f60c with: scripts: '["lint:scripts", "lint:styles", "lint:types"]' @@ -41,7 +45,7 @@ jobs: id: slug run: | slug=$(jq -r '.extra["text-domain"] // empty' composer.json) - if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + if ! [[ "$slug" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]]; then echo "::error::composer.json's extra.text-domain must be the plugin's kebab-case slug — got '$slug'." exit 1 fi @@ -58,6 +62,7 @@ jobs: readme-lint: name: readme.txt lint runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -66,18 +71,3 @@ jobs: with: path: readme.txt fail-on: error - - changelog: - name: Changelog fragments - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 - with: - php-version: '8.5' - coverage: none - tools: composer:v2 - - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 - - run: composer changelog:validate diff --git a/plugins/linked-orders-for-woocommerce/.github/workflows/release.yml b/plugins/linked-orders-for-woocommerce/.github/workflows/release.yml index eba32e9..d6b730a 100644 --- a/plugins/linked-orders-for-woocommerce/.github/workflows/release.yml +++ b/plugins/linked-orders-for-woocommerce/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: id: slug run: | slug=$(jq -r '.extra["text-domain"] // empty' composer.json) - if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + if ! [[ "$slug" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]]; then echo "::error::composer.json's extra.text-domain must be the plugin's kebab-case slug — got '$slug'." exit 1 fi diff --git a/plugins/linked-orders-for-woocommerce/.github/workflows/tests-nightly.yml b/plugins/linked-orders-for-woocommerce/.github/workflows/tests-nightly.yml index eec84c9..69824e8 100644 --- a/plugins/linked-orders-for-woocommerce/.github/workflows/tests-nightly.yml +++ b/plugins/linked-orders-for-woocommerce/.github/workflows/tests-nightly.yml @@ -19,6 +19,15 @@ jobs: composer-script: 'test:integration:run' wp-env-config-file: '.wp-env.tests.json' + integration-multisite: + name: Integration (multisite) + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c + with: + php-version: '8.5' + wp-env-core: 'https://wordpress.org/nightly-builds/wordpress-latest.zip' + composer-script: 'test:integration:multisite:run' + wp-env-config-file: '.wp-env.multisite.json' + e2e: name: E2E uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@b107001cce9f2960495d9d50481c8540d228f60c diff --git a/plugins/linked-orders-for-woocommerce/.github/workflows/tests.yml b/plugins/linked-orders-for-woocommerce/.github/workflows/tests.yml index fb6ef15..466d0d7 100644 --- a/plugins/linked-orders-for-woocommerce/.github/workflows/tests.yml +++ b/plugins/linked-orders-for-woocommerce/.github/workflows/tests.yml @@ -6,6 +6,10 @@ on: pull_request: workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -22,8 +26,6 @@ jobs: name: WordPress versions runs-on: ubuntu-latest timeout-minutes: 5 - permissions: - contents: read outputs: versions: ${{ steps.versions.outputs.versions }} steps: @@ -41,7 +43,7 @@ jobs: echo "::error::$entry declares no Requires at least header." exit 1 fi - offers=$(curl -fsS https://api.wordpress.org/core/version-check/1.7/) + offers=$(curl -fsS --retry 3 --retry-all-errors --max-time 20 https://api.wordpress.org/core/version-check/1.7/) versions=$(printf '%s' "$offers" | jq -c --arg floor "$floor" '[.offers[].version | select((split(".")[0:2] | map(tonumber)) >= ($floor | split(".")[0:2] | map(tonumber)))] | unique | if length == 0 then error("No WordPress release at or above \($floor).") else . end') echo "versions=$versions" >> "$GITHUB_OUTPUT" @@ -59,6 +61,20 @@ jobs: composer-script: 'test:integration:run' wp-env-config-file: '.wp-env.tests.json' + integration-multisite: + name: Integration (multisite) + needs: wordpress + strategy: + fail-fast: false + matrix: + wp: ${{ fromJSON(needs.wordpress.outputs.versions) }} + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c + with: + php-version: '8.5' + wp-version: ${{ matrix.wp }} + composer-script: 'test:integration:multisite:run' + wp-env-config-file: '.wp-env.multisite.json' + e2e: name: E2E needs: wordpress diff --git a/plugins/linked-orders-for-woocommerce/.github/workflows/workflow-checks.yml b/plugins/linked-orders-for-woocommerce/.github/workflows/workflow-checks.yml index bafdc75..e954a68 100644 --- a/plugins/linked-orders-for-woocommerce/.github/workflows/workflow-checks.yml +++ b/plugins/linked-orders-for-woocommerce/.github/workflows/workflow-checks.yml @@ -3,12 +3,11 @@ name: Workflow Checks on: push: branches: [trunk] - paths: ['.github/workflows/**'] + paths: ['.github/**'] pull_request: - paths: ['.github/workflows/**'] + paths: ['.github/**'] schedule: - # Weekly Monday 06:00 UTC. Keeps zizmor's online audits (e.g. - # known-vulnerable-actions) fresh between workflow changes. + # Re-runs zizmor's online audits between workflow changes. - cron: '0 6 * * 1' workflow_dispatch: @@ -20,6 +19,7 @@ permissions: {} jobs: checks: + name: Workflow Checks permissions: contents: read security-events: write diff --git a/plugins/linked-orders-for-woocommerce/.gitignore b/plugins/linked-orders-for-woocommerce/.gitignore index 216741b..df3cc85 100644 --- a/plugins/linked-orders-for-woocommerce/.gitignore +++ b/plugins/linked-orders-for-woocommerce/.gitignore @@ -1,7 +1,15 @@ vendor-prefixed assets/build tests/.cache -.wp-env.override.json +.wp-env*.override.json + +# Secret material is never tracked, so these match at any depth as a backstop against an accidental git add. +.env* +*.pem +*.key +id_rsa* +.npmrc +auth.json # Created by https://www.toptal.com/developers/gitignore/api/node,linux,macos,phpunit,windows,composer,phpstorm+all,visualstudiocode # Edit at https://www.toptal.com/developers/gitignore?templates=node,linux,macos,phpunit,windows,composer,phpstorm+all,visualstudiocode diff --git a/plugins/linked-orders-for-woocommerce/.wp-env.multisite.json b/plugins/linked-orders-for-woocommerce/.wp-env.multisite.json new file mode 100644 index 0000000..986fc8a --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/.wp-env.multisite.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://schemas.wp.org/trunk/wp-env.json", + "core": "WordPress/WordPress#tags/7.1.4", + "phpVersion": "8.5", + "port": 8828, + "testsEnvironment": false, + "multisite": true, + "mappings": { + "wp-content/plugins/linked-orders-for-woocommerce": ".", + "wp-content/plugins/woocommerce": "./vendor/wp-plugin/woocommerce" + }, + "config": { + "WP_DEBUG": true, + "SCRIPT_DEBUG": true, + "WP_ENVIRONMENT_TYPE": false + }, + "lifecycleScripts": { + "afterStart": "node_modules/.bin/wp-env --config .wp-env.multisite.json run cli wp plugin activate woocommerce --network && node_modules/.bin/wp-env --config .wp-env.multisite.json run cli wp wc hpos enable --ignore-plugin-compatibility && node_modules/.bin/wp-env --config .wp-env.multisite.json run cli wp plugin activate linked-orders-for-woocommerce --network" + } +} diff --git a/plugins/linked-orders-for-woocommerce/AGENTS.md b/plugins/linked-orders-for-woocommerce/AGENTS.md index e16d4dc..7a67805 100644 --- a/plugins/linked-orders-for-woocommerce/AGENTS.md +++ b/plugins/linked-orders-for-woocommerce/AGENTS.md @@ -41,6 +41,7 @@ changelog/ the pending entries changelog:write turns into CHAN composer packages-install # dependencies, then php-scoper into vendor-prefixed/ composer test:unit composer test:integration # starts .wp-env.tests.json, then runs test:integration:run, which builds the scripts first +composer test:integration:multisite # starts .wp-env.multisite.json, then runs the Integration suite's multisite group, which the default run skips composer quality-check # manifest and changelog validation, PHPCS (both profiles), PHPStan (both profiles), Unit and Integration composer changelog:add # stages an entry in changelog/, which the release step in the monorepo's CONTRIBUTING.md writes npm run setup # both installs, the scripts' build, then the development site @@ -55,8 +56,9 @@ Run wp-scripts only through the npm scripts or `node_modules/.bin/wp-scripts`: ` - `.wp-env.json`: development, as `local`, on port 8822, behind `npm run setup` and `wp-env:*`. The framework rethrows boot and migration failures there. - `.wp-env.tests.json`: Integration and end to end, as `production`, on port 8823, behind `wp-env:tests:*`, `composer test:integration` and `npm run test:e2e`. The end-to-end suite deactivates and reactivates WooCommerce there. +- `.wp-env.multisite.json`: a network with WooCommerce and the plugin network-activated, as `production`, on port 8828, behind `wp-env:multisite:*` and `composer test:integration:multisite`, for the Integration suite's `multisite` group. -Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI runs Integration and end to end on every WordPress branch from the entry's `Requires at least` up, and nightly on WordPress trunk. +Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI runs Integration and end to end on every WordPress branch from the entry's `Requires at least` up, and nightly on WordPress trunk. ## Conventions @@ -66,11 +68,15 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run 4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. 5. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input. 6. **Options and data.** A setting is read through v1's API in `includes/settings.php`, whose validation is each setting's one typed read and whose reads apply v1's filters, safeguards excepted. v1's settings keep their `dws-lowc_` keys and its order links their `_dws_lo_` meta; anything new lives under `dws_lowc_`, or `_dws_lowc_` for hidden meta. The footprint covers those prefixes and v1's other rows by name. -7. **Order storage.** Order code goes through WC CRUD, `get_edit_order_url()` and WooCommerce's screen ids and list hooks, never the posts table, and every Integration row whose outcome the order storage can change runs on all four storages through `OrderStorage`: posts and the order tables, each with the sync on and off. Rows that check only the order of hooks, or a stand-in plugin, use one. +7. **Order storage.** Order code goes through WC CRUD, `get_edit_order_url()` and WooCommerce's screen ids and list hooks, never the posts table, and every Integration row whose outcome the order storage can change runs on all four storages through `OrderStorageTrait`: posts and the order tables, each with the sync on and off. Rows that check only the order of hooks, or a stand-in plugin, use one. 8. **v1 hooks.** A hook v1 fired keeps its v1 literal, fired first through `apply_filters_deprecated()` or `do_action_deprecated()` with `'2.0.0'` and the v2 name, then the v2 name, and `DeprecatedHooksTest` names the pair. 9. **Safeguards** live in `includes/safeguards.php`, call only WordPress and that file, and relax only through their own filter, through `dws_lowc_is_relaxed()` (the template's recipes). The entry loads them before the floor check, so they parse on PHP 7.4 and call only WordPress APIs present at the oldest WordPress the bootstrap package runs on, or guard them, which `phpstan.safeguards.neon` checks. 10. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. +## Contract + +The plugin's contract is the elements tagged `@api`, the hooks it fires, and its option, meta and capability names and REST routes; breaking it ships as the next major. Every other function, constant and class, `src/` included, is internal and changes in any release. + ## Style - Every PHP file opens with `boot(); + $plugin = dws_lowc_plugin(); } catch ( Throwable $error ) { dws_lowc_handle_build_error( $error ); + return; } + + $plugin->boot(); // PluginBoot reports a failed composition itself, and a hook listener's throw is not a build error. } /** @@ -75,14 +80,19 @@ function dws_lowc_boot(): void { */ function dws_lowc_deactivate(): void { try { - dws_lowc_plugin()->deactivate(); + $plugin = dws_lowc_plugin(); } catch ( Throwable $error ) { dws_lowc_handle_build_error( $error ); + return; } + + $plugin->deactivate(); } /** - * Declares that the plugin works with WooCommerce's order tables, which its tests cover on both order storages. + * Declares that the plugin works with WooCommerce's order tables. + * + * Each WooCommerce feature flag is declared from evidence, here the tests that cover both order storages. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/linked-orders-for-woocommerce/includes/assets.php b/plugins/linked-orders-for-woocommerce/includes/assets.php index 9c55f48..9a9932c 100644 --- a/plugins/linked-orders-for-woocommerce/includes/assets.php +++ b/plugins/linked-orders-for-woocommerce/includes/assets.php @@ -13,7 +13,7 @@ defined( 'ABSPATH' ) || exit; /** - * Returns the payload wp-scripts generates beside a built file, which lists its dependencies, script module imports included, and its version. + * Returns the payload wp-scripts generates beside a built file. * * @since 2.0.0 * @version 2.0.0 @@ -28,8 +28,7 @@ function dws_lowc_get_asset( string $path ): array { * * @var array{dependencies: list, version: string, type?: string, handle?: string} $asset */ - $asset = require plugin_dir_path( DWS_LOWC_FILE ) . dirname( $path ) . '/' . basename( $path, '.' . pathinfo( $path, PATHINFO_EXTENSION ) ) . '.asset.php'; - + $asset = require plugin_dir_path( DWS_LOWC_FILE ) . dirname( $path ) . '/' . pathinfo( $path, PATHINFO_FILENAME ) . '.asset.php'; return $asset; } @@ -39,9 +38,11 @@ function dws_lowc_get_asset( string $path ): array { * @since 2.0.0 * @version 2.0.0 * - * @param non-empty-string $handle The script's handle. - * @param non-empty-string $path The built script's path, relative to the plugin's directory. - * @param array{strategy?: 'async'|'defer', in_footer?: bool, fetchpriority?: 'auto'|'low'|'high'} $args Optional. How the script loads. Default none, which loads it in the head. + * @phpstan-param array{strategy?: 'async'|'defer', in_footer?: bool, fetchpriority?: 'auto'|'low'|'high'} $args + * + * @param non-empty-string $handle The script's handle. + * @param non-empty-string $path The built script's path, relative to the plugin's directory. + * @param array $args Optional. How the script loads. Default none, which loads it in the head. * * @return bool */ @@ -63,12 +64,11 @@ function dws_lowc_register_script( string $handle, string $path, array $args = a * * @param non-empty-string $handle The stylesheet's handle. * @param non-empty-string $path The built stylesheet's path, relative to the plugin's directory. - * @param string $media Optional. The media the stylesheet applies to. Default 'all'. * * @return bool */ -function dws_lowc_register_style( string $handle, string $path, string $media = 'all' ): bool { - $registered = wp_register_style( $handle, plugin_dir_url( DWS_LOWC_FILE ) . $path, array(), dws_lowc_get_asset( $path )['version'], $media ); +function dws_lowc_register_style( string $handle, string $path ): bool { + $registered = wp_register_style( $handle, plugin_dir_url( DWS_LOWC_FILE ) . $path, array(), dws_lowc_get_asset( $path )['version'] ); if ( $registered ) { wp_style_add_data( $handle, 'rtl', 'replace' ); } diff --git a/plugins/linked-orders-for-woocommerce/includes/deprecated.php b/plugins/linked-orders-for-woocommerce/includes/deprecated.php index 3a6d144..d128ba7 100644 --- a/plugins/linked-orders-for-woocommerce/includes/deprecated.php +++ b/plugins/linked-orders-for-woocommerce/includes/deprecated.php @@ -31,6 +31,8 @@ /** * The plugin's untranslated name. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lowc_get_plugin_name(). @@ -40,6 +42,8 @@ /** * The plugin's version. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lowc_get_plugin_version(). @@ -49,6 +53,8 @@ /** * The path of the plugin's main file. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use DWS_LOWC_FILE. @@ -58,6 +64,8 @@ /** * The oldest PHP version the plugin runs on. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -67,6 +75,8 @@ /** * The oldest WordPress version the plugin runs on. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -88,6 +98,8 @@ /** * Returns the plugin's name, translated once init has fired. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lowc_get_plugin_name(). @@ -96,13 +108,14 @@ */ function dws_lowc_name(): string { _deprecated_function( __FUNCTION__, '2.0.0', 'dws_lowc_get_plugin_name()' ); - return dws_lowc_get_plugin_name(); } /** * Returns the plugin's version. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lowc_get_plugin_version(). @@ -111,13 +124,14 @@ function dws_lowc_name(): string { */ function dws_lowc_version(): string { _deprecated_function( __FUNCTION__, '2.0.0', 'dws_lowc_get_plugin_version()' ); - return dws_lowc_get_plugin_version(); } /** * Returns the path of the plugin's main file. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -126,13 +140,14 @@ function dws_lowc_version(): string { */ function dws_lowc_path(): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return DWS_LOWC_PATH; } /** * Returns the oldest PHP version the plugin runs on. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -141,13 +156,14 @@ function dws_lowc_path(): string { */ function dws_lowc_min_php(): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return DWS_LOWC_MIN_PHP; } /** * Returns the oldest WordPress version the plugin runs on. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -156,13 +172,14 @@ function dws_lowc_min_php(): string { */ function dws_lowc_min_wp(): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return DWS_LOWC_MIN_WP; } /** * Returns a hook name as v1 built it for the plugin. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -174,13 +191,14 @@ function dws_lowc_min_wp(): string { */ function dws_lowc_get_hook_tag( string $name, $extra = array() ): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return dws_lowc_join_v1_hook_tag( 'linked_orders_for_woocommerce', $name, $extra ); } /** * Returns a hook name as v1 built it for one of its components, or null for an id v1 had no component for. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -193,7 +211,6 @@ function dws_lowc_get_hook_tag( string $name, $extra = array() ): string { */ function dws_lowc_get_component_hook_tag( string $component_id, string $name, $extra = array() ): ?string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return in_array( $component_id, DWS_LOWC_V1_COMPONENTS, true ) ? dws_lowc_join_v1_hook_tag( 'linked_orders_for_woocommerce/' . str_replace( '-', '_', $component_id ), $name, $extra ) : null; } diff --git a/plugins/linked-orders-for-woocommerce/includes/footprint.php b/plugins/linked-orders-for-woocommerce/includes/footprint.php index d673e65..6e0c0b2 100644 --- a/plugins/linked-orders-for-woocommerce/includes/footprint.php +++ b/plugins/linked-orders-for-woocommerce/includes/footprint.php @@ -13,7 +13,9 @@ defined( 'ABSPATH' ) || exit; /** - * Returns the plugin's stored data by kind, which covers every option, transient, user meta key and scheduled job under the plugin's prefix, and what v1 stored under its own names: its settings, version rows and notices, the order links' meta and the capabilities. + * Returns the plugin's stored data by kind. + * + * It also covers what v1 stored under its own names. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/linked-orders-for-woocommerce/includes/linking.php b/plugins/linked-orders-for-woocommerce/includes/linking.php index 2c84860..aea1f13 100644 --- a/plugins/linked-orders-for-woocommerce/includes/linking.php +++ b/plugins/linked-orders-for-woocommerce/includes/linking.php @@ -17,6 +17,8 @@ /** * Returns an order's node, or null when the value names no order of a supported type. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -35,6 +37,8 @@ function dws_lowc_get_order_node( $order ): ?DWS_Order_Node { /** * Returns whether an order is at the top of its tree, or null when it has no node. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -44,8 +48,7 @@ function dws_lowc_get_order_node( $order ): ?DWS_Order_Node { */ function dws_lowc_is_root_order( $order ): ?bool { $node = dws_lowc_get_order_node( $order ); - - return null === $node ? null : 0 === $node->get_depth(); + return is_null( $node ) ? null : 0 === $node->get_depth(); } /** @@ -53,6 +56,8 @@ function dws_lowc_is_root_order( $order ): ?bool { * * A link reads and rewrites the parent's list of children, so one lock serializes links across requests, and each reads both orders' meta from the database once it holds the lock. A link holds it for milliseconds, so the next one waits for it rather than refusing at once. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -64,7 +69,7 @@ function dws_lowc_is_root_order( $order ): ?bool { function dws_lowc_link_orders( int $parent_id, int $child_id ): ?bool { $parent = dws_lowc_get_order_node( $parent_id ); $child = dws_lowc_get_order_node( $child_id ); - if ( null === $parent || null === $child ) { + if ( is_null( $parent ) || is_null( $child ) ) { return null; } @@ -106,6 +111,8 @@ function dws_lowc_link_orders( int $parent_id, int $child_id ): ?bool { /** * Returns the node at the top of an order's tree, which is the order's own for a root or for a child whose parent was deleted, or null when the order has no node. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -115,7 +122,7 @@ function dws_lowc_link_orders( int $parent_id, int $child_id ): ?bool { */ function dws_lowc_get_root_order( $order ): ?DWS_Order_Node { $node = dws_lowc_get_order_node( $order ); - while ( null !== $node && $node->has_parent() ) { + while ( ! is_null( $node ) && $node->has_parent() ) { $node = $node->get_parent(); } @@ -125,6 +132,8 @@ function dws_lowc_get_root_order( $order ): ?DWS_Order_Node { /** * Returns the ids of an order and its descendants, each child before the next with its own descendants, or null when the order has no node. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -134,7 +143,7 @@ function dws_lowc_get_root_order( $order ): ?DWS_Order_Node { */ function dws_lowc_get_orders_tree( $order ): ?array { $node = dws_lowc_get_order_node( $order ); - if ( null === $node ) { + if ( is_null( $node ) ) { return null; } diff --git a/plugins/linked-orders-for-woocommerce/includes/metadata.php b/plugins/linked-orders-for-woocommerce/includes/metadata.php index a5ae8df..7c5ff15 100644 --- a/plugins/linked-orders-for-woocommerce/includes/metadata.php +++ b/plugins/linked-orders-for-woocommerce/includes/metadata.php @@ -1,8 +1,8 @@ 'Plugin Name' ) )['Name']; - return 0 < did_action( 'init' ) ? __( $name, 'linked-orders-for-woocommerce' ) : $name; // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- The header's name reaches the POT through make-pot's header extraction. } /** * Returns the plugin's version from its header. * + * @api + * * @since 2.0.0 * @version 2.0.0 * diff --git a/plugins/linked-orders-for-woocommerce/includes/migrations.php b/plugins/linked-orders-for-woocommerce/includes/migrations.php index ae61ef9..9172c19 100644 --- a/plugins/linked-orders-for-woocommerce/includes/migrations.php +++ b/plugins/linked-orders-for-woocommerce/includes/migrations.php @@ -25,7 +25,9 @@ function dws_lowc_get_migration_steps(): array { } /** - * Stores the settings' defaults, autoloaded, so a site that never saved them reads them without a query, and keeps every value a v1 site stored. + * Stores the settings' defaults. + * + * Every value a v1 site stored stays as it is. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/linked-orders-for-woocommerce/includes/options.php b/plugins/linked-orders-for-woocommerce/includes/options.php index 9b5737e..c57abf7 100644 --- a/plugins/linked-orders-for-woocommerce/includes/options.php +++ b/plugins/linked-orders-for-woocommerce/includes/options.php @@ -66,6 +66,5 @@ */ function dws_lowc_validate_max_depth( $submitted ) { $depth = is_string( $submitted ) ? filter_var( $submitted, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ) : false; - return false === $depth ? new WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 2.', 'linked-orders-for-woocommerce' ) ) : (string) $depth; } diff --git a/plugins/linked-orders-for-woocommerce/includes/post-type.php b/plugins/linked-orders-for-woocommerce/includes/post-type.php index 1ce9d6c..33d3f45 100644 --- a/plugins/linked-orders-for-woocommerce/includes/post-type.php +++ b/plugins/linked-orders-for-woocommerce/includes/post-type.php @@ -15,6 +15,8 @@ /** * Returns the order types whose orders can be linked. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -55,13 +57,14 @@ function dws_lowc_get_supported_order_types(): array { */ function dws_lowc_get_singular_name( WP_Post_Type $post_type ): string { $name = $post_type->labels->singular_name ?? null; - return is_string( $name ) ? $name : $post_type->label; } /** * Returns whether an order is of a type that can be linked, or null when it is not an order. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -75,13 +78,14 @@ function dws_lowc_is_supported_order( $order ): ?bool { } $order = wc_get_order( $order ); - return $order instanceof WC_Order ? in_array( $order->get_type(), dws_lowc_get_supported_order_types(), true ) : null; } /** * Returns the statuses, without their 'wc-' prefix, that an order must have for a child to be created from it. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -92,7 +96,7 @@ function dws_lowc_is_supported_order( $order ): ?bool { */ function dws_lowc_get_valid_statuses_for_new_child( string $order_type = 'shop_order', ?WC_Order $order = null ): array { $statuses = array_map( array( OrderUtil::class, 'remove_status_prefix' ), array_keys( wc_get_order_statuses() ) ); - $order_type = null === $order ? $order_type : $order->get_type(); + $order_type = is_null( $order ) ? $order_type : $order->get_type(); /** * Filters the statuses an order must have for a child to be created from it. @@ -139,6 +143,8 @@ function dws_lowc_get_create_child_url( int $parent_id ): string { * * Returns null when the parent is missing or unsupported, or when the creator is not callable or creates no supported order, the creator's error when it fails, and an error when the link is refused, after deleting the child. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -230,7 +236,6 @@ function dws_lowc_create_linked_child( int $parent_id, array $args = array() ) { $child->save(); if ( true !== dws_lowc_link_orders( $parent_id, $child->get_id() ) ) { $child->delete( true ); - return new WP_Error( 'dws_lowc_link_refused', __( 'another order was being linked at the same time. Please try again.', 'linked-orders-for-woocommerce' ) ); } diff --git a/plugins/linked-orders-for-woocommerce/includes/safeguards.php b/plugins/linked-orders-for-woocommerce/includes/safeguards.php index 82cc3f9..4bce7d1 100644 --- a/plugins/linked-orders-for-woocommerce/includes/safeguards.php +++ b/plugins/linked-orders-for-woocommerce/includes/safeguards.php @@ -1,6 +1,8 @@ getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The plugin's logger is part of the build that failed. + // A partial deploy fails the same way on every request, so each distinct message is logged about once an hour, as best effort. + $key = 'dws_lowc_build_error_' . md5( $error->getMessage() ); + if ( false === get_transient( $key ) ) { + set_transient( $key, 1, HOUR_IN_SECONDS ); + error_log( 'Linked Orders for WooCommerce did not finish loading: ' . $error->getMessage() . ' in ' . $error->getFile() . ':' . $error->getLine() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The plugin's logger is part of the build that failed. + } + add_action( 'all_admin_notices', 'dws_lowc_render_build_notice' ); } diff --git a/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php b/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php index 46d7a0d..c073432 100644 --- a/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php +++ b/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php @@ -7,6 +7,8 @@ * * It keeps v1's global name, which other plugins type-hint, and calls to it that name no method of its own reach the order. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @@ -112,7 +114,7 @@ final class DWS_Order_Node { public function __construct( mixed $order ) { $order = wc_get_order( $order ); $post_type = $order instanceof WC_Order && true === dws_lowc_is_supported_order( $order ) ? get_post_type_object( $order->get_type() ) : null; - if ( ! $order instanceof WC_Order || null === $post_type ) { + if ( ! $order instanceof WC_Order || is_null( $post_type ) ) { throw new InvalidArgumentException( 'The order is missing or of an unsupported type.' ); } @@ -147,7 +149,6 @@ public function __get( string $name ): mixed { */ public function __call( string $name, array $arguments ): mixed { $method = array( $this->order, $name ); - return is_callable( $method ) ? $method( ...$arguments ) : null; } @@ -189,7 +190,6 @@ public function get_post_type(): WP_Post_Type { */ public function get_depth(): int { $this->maybe_read(); - return $this->depth; } @@ -219,7 +219,7 @@ public function get_parent(): ?DWS_Order_Node { * @return bool */ public function has_parent(): bool { - return null !== $this->get_parent(); + return ! is_null( $this->get_parent() ); } /** @@ -233,7 +233,6 @@ public function has_parent(): bool { public function get_children(): array { $this->maybe_read(); $this->children ??= array_values( array_filter( array_map( static fn ( ?int $id ): ?DWS_Order_Node => dws_lowc_get_order_node( $id ), $this->child_ids ) ) ); - return $this->children; } @@ -323,7 +322,6 @@ public function add_child( DWS_Order_Node $child ): bool { $child->set_parent( $this ); $this->children = array( ...$this->get_children(), $child ); - return true; } diff --git a/plugins/linked-orders-for-woocommerce/package.json b/plugins/linked-orders-for-woocommerce/package.json index fc72df9..df4050a 100644 --- a/plugins/linked-orders-for-woocommerce/package.json +++ b/plugins/linked-orders-for-woocommerce/package.json @@ -26,30 +26,50 @@ "setup": "npm run packages-install:all && npm run build && npm run wp-env:start", "packages-install:all": "composer packages-install && npm run packages-install", "packages-update:all": "composer packages-update && npm run packages-update && npm run packages-update:wp", + "packages-install": "npm install", "packages-update": "npm update", "packages-update:wp": "wp-scripts packages-update", - "wp-env": "wp-env", + + "build": "npm run build:assets", + "build:assets": "wp-scripts build orders-list-table=./assets/src/orders-list-table.ts orders-list-table-icons=./assets/src/orders-list-table-icons.scss --output-path=assets/build", + "start": "npm run start:assets", + "start:assets": "wp-scripts start orders-list-table=./assets/src/orders-list-table.ts orders-list-table-icons=./assets/src/orders-list-table-icons.scss --output-path=assets/build", + + "format": "npm run format:scripts && npm run format:styles", + "format:scripts": "wp-scripts format assets/src tests/EndToEnd eslint.config.mjs stylelint.config.js playwright.config.js", + "format:styles": "npm run lint:styles -- --fix", + "format:all": "( composer format:php || [ $? -eq 1 ] ) && npm run format", + + "lint": "npm run lint:scripts && npm run lint:styles && npm run lint:types", + "lint:scripts": "wp-scripts lint-js assets/src tests/EndToEnd eslint.config.mjs stylelint.config.js playwright.config.js", + "lint:styles": "wp-scripts lint-style 'assets/src/**/*.scss'", + "lint:types": "tsc", + "lint:all": "composer lint:php && npm run lint", + "wp-env:start": "wp-env start", "wp-env:stop": "wp-env stop", "wp-env:update": "wp-env start --update", "wp-env:destroy": "wp-env destroy", + "wp-env:tests:start": "wp-env --config .wp-env.tests.json start", "wp-env:tests:stop": "wp-env --config .wp-env.tests.json stop", "wp-env:tests:update": "wp-env --config .wp-env.tests.json start --update", "wp-env:tests:destroy": "wp-env --config .wp-env.tests.json destroy", - "build": "npm run build:assets", - "build:assets": "wp-scripts build orders-list-table=./assets/src/orders-list-table.ts orders-list-table-icons=./assets/src/orders-list-table-icons.scss --output-path=assets/build", - "start": "npm run start:assets", - "start:assets": "wp-scripts start orders-list-table=./assets/src/orders-list-table.ts orders-list-table-icons=./assets/src/orders-list-table-icons.scss --output-path=assets/build", - "lint": "npm run lint:scripts && npm run lint:styles && npm run lint:types", - "lint:scripts": "wp-scripts lint-js assets/src tests/EndToEnd", - "lint:styles": "wp-scripts lint-style 'assets/src/**/*.scss'", - "lint:types": "tsc", - "audit": "composer audit && npm audit", + + "wp-env:multisite:start": "wp-env --config .wp-env.multisite.json start", + "wp-env:multisite:stop": "wp-env --config .wp-env.multisite.json stop", + "wp-env:multisite:update": "wp-env --config .wp-env.multisite.json start --update", + "wp-env:multisite:destroy": "wp-env --config .wp-env.multisite.json destroy", + + "audit": "composer audit --locked --abandoned=report && npm audit --omit=dev --audit-level=high", "audit:fix": "npm audit fix", "check:engines": "wp-scripts check-engines", "check:licenses": "wp-scripts check-licenses", - "test:e2e": "playwright test --config=playwright.config.js" + + "test:e2e": "playwright test --config=playwright.config.js", + "test:all": "composer test && npm run test:e2e", + + "quality-check:all": "composer quality-check && npm run lint && npm run test:e2e" } } diff --git a/plugins/linked-orders-for-woocommerce/phpcs.dist.xml b/plugins/linked-orders-for-woocommerce/phpcs.dist.xml index d541a27..2b54bad 100644 --- a/plugins/linked-orders-for-woocommerce/phpcs.dist.xml +++ b/plugins/linked-orders-for-woocommerce/phpcs.dist.xml @@ -23,4 +23,19 @@ + + + + + + + + + + + + + + */models/DWS_Order_Node\.php + diff --git a/plugins/linked-orders-for-woocommerce/phpstan.dist.neon b/plugins/linked-orders-for-woocommerce/phpstan.dist.neon index 4274969..59038ea 100644 --- a/plugins/linked-orders-for-woocommerce/phpstan.dist.neon +++ b/plugins/linked-orders-for-woocommerce/phpstan.dist.neon @@ -1,4 +1,4 @@ -# The WordPress extensions are listed here because composer.json keeps them from installing themselves, and before configs' profile because wp-compat's neon resets WPCompat.requiresAtLeast to null. +# The WordPress extensions are listed here because composer.json keeps them from installing themselves. includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon - vendor/johnbillion/wp-compat/extension.neon @@ -7,13 +7,17 @@ includes: parameters: paths: - - linked-orders-for-woocommerce.php - functions.php - uninstall.php - includes - models - src + # phpstan.safeguards.neon analyzes the entry and the safeguards at the oldest runtime they parse on, so this profile only scans them. + excludePaths: + - includes/safeguards.php scanFiles: + - linked-orders-for-woocommerce.php + - includes/safeguards.php - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php - vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php - tests/Fixtures/class-wc-seq-order-number-pro.php @@ -21,3 +25,7 @@ parameters: # The scoped packages load through vendor-prefixed/scoper-autoload.php, which PHPStan does not read. scanDirectories: - vendor-prefixed + # wp-compat reads the floor from the entry's Requires at least only while requiresAtLeast, which configs' profile sets, is null. + WPCompat: + pluginFile: linked-orders-for-woocommerce.php + requiresAtLeast: null diff --git a/plugins/linked-orders-for-woocommerce/phpstan.safeguards.neon b/plugins/linked-orders-for-woocommerce/phpstan.safeguards.neon index e2f6abc..6e1f37a 100644 --- a/plugins/linked-orders-for-woocommerce/phpstan.safeguards.neon +++ b/plugins/linked-orders-for-woocommerce/phpstan.safeguards.neon @@ -1,4 +1,4 @@ -# The safeguards load before the floor gate, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on, which the framework's phpstan.bootstrap.neon sets; the WordPress extensions are listed because composer.json keeps them from installing themselves. +# The entry and the safeguards run before the floor gate, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on, which the framework's phpstan.bootstrap.neon sets; the WordPress extensions are listed because composer.json keeps them from installing themselves. includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon - vendor/johnbillion/wp-compat/extension.neon @@ -7,7 +7,20 @@ includes: parameters: paths: + - linked-orders-for-woocommerce.php - includes/safeguards.php + # A safeguard calls WooCommerce where the plugin requires it. + scanFiles: + - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php phpVersion: 70400 WPCompat: requiresAtLeast: '5.2' + # The entry loads the scoped bootstrap package by path, before any autoloader. + scanDirectories: + - vendor-prefixed/ahegyes/wp-framework-bootstrap + ignoreErrors: + # The entry's build-error handler runs only once the floor gate has passed. + - + identifier: WPCompat.functionNotAvailable + message: '#^wp_get_environment_type\(\) #' + path: linked-orders-for-woocommerce.php diff --git a/plugins/linked-orders-for-woocommerce/phpunit.dist.xml b/plugins/linked-orders-for-woocommerce/phpunit.dist.xml index c04a1e5..62148d3 100644 --- a/plugins/linked-orders-for-woocommerce/phpunit.dist.xml +++ b/plugins/linked-orders-for-woocommerce/phpunit.dist.xml @@ -27,6 +27,8 @@ src + includes + models diff --git a/plugins/linked-orders-for-woocommerce/playwright.config.js b/plugins/linked-orders-for-woocommerce/playwright.config.js index 966d4c2..e1fdf65 100644 --- a/plugins/linked-orders-for-woocommerce/playwright.config.js +++ b/plugins/linked-orders-for-woocommerce/playwright.config.js @@ -1,6 +1,8 @@ const { defineConfig } = require( '@playwright/test' ); -// The port of .wp-env.tests.json, so the suite runs on the production tests environment and leaves the development one's data alone. -const baseConfig = require( '@ahegyes/wordpress-configs/node/playwright.config.base.js' )( { port: 8823 } ); +const baseConfig = + require( '@ahegyes/wordpress-configs/node/playwright.config.base.js' )( { + port: require( './.wp-env.tests.json' ).port, + } ); module.exports = defineConfig( { ...baseConfig, diff --git a/plugins/linked-orders-for-woocommerce/scoper.inc.php b/plugins/linked-orders-for-woocommerce/scoper.inc.php index 036ecd7..2ed6a86 100644 --- a/plugins/linked-orders-for-woocommerce/scoper.inc.php +++ b/plugins/linked-orders-for-woocommerce/scoper.inc.php @@ -2,6 +2,9 @@ /** * Returns the php-scoper config that prefixes the framework and PHP-DI with the plugin's namespace. * + * @since 2.0.0 + * @version 2.0.0 + * * @package DeepWebSolutions\LinkedOrders */ diff --git a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php index 3451ba1..961a2fa 100644 --- a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php +++ b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php @@ -68,7 +68,7 @@ public function complete_descendants( int $order_id ): void { * @param int $descendant_id The descendant's id. */ $node = true === apply_filters( 'dws_lowc/autocompletion/should_autocomplete', $should_autocomplete, $descendant_id ) ? dws_lowc_get_order_node( $descendant_id ) : null; - if ( null === $node ) { + if ( \is_null( $node ) ) { continue; } diff --git a/plugins/linked-orders-for-woocommerce/src/ChildCreation.php b/plugins/linked-orders-for-woocommerce/src/ChildCreation.php index 9f30fca..7544f4e 100644 --- a/plugins/linked-orders-for-woocommerce/src/ChildCreation.php +++ b/plugins/linked-orders-for-woocommerce/src/ChildCreation.php @@ -66,7 +66,7 @@ public function create_linked_child(): never { ) ) ?? 0; $node = dws_lowc_get_order_node( $parent_id ); - if ( null === $node ) { + if ( \is_null( $node ) ) { /* translators: %d: The parent order's id. */ $this->notices->add( new Notice( 'linked-child-missing-parent', esc_html( \sprintf( __( 'There is no order %d to create a linked child for.', 'linked-orders-for-woocommerce' ), $parent_id ) ) ) ); wp_safe_redirect( wp_get_referer() ?: admin_url( 'index.php' ) ); diff --git a/plugins/linked-orders-for-woocommerce/src/ListTable.php b/plugins/linked-orders-for-woocommerce/src/ListTable.php index 17d21e3..2fc7890 100644 --- a/plugins/linked-orders-for-woocommerce/src/ListTable.php +++ b/plugins/linked-orders-for-woocommerce/src/ListTable.php @@ -49,7 +49,7 @@ public function register_hooks(): void { */ public function add_depth_column( \WP_Screen $screen ): void { $type = $this->can_see() ? self::get_list_type( $screen ) : null; - if ( null === $type ) { + if ( \is_null( $type ) ) { return; } @@ -96,7 +96,6 @@ public function insert_depth_column( array $columns ): array { $preceding = apply_filters( 'dws_lowc/list_table/preceding_column', $preceding, $type, $columns ); $position = \array_search( $preceding, \array_keys( $columns ), true ); $position = false === $position ? \count( $columns ) : $position + 1; - return \array_slice( $columns, 0, $position, true ) + array( 'dws_lo_depth' => _x( 'Depth', 'list table heading', 'linked-orders-for-woocommerce' ) ) + \array_slice( $columns, $position, null, true ); } @@ -113,7 +112,7 @@ public function insert_depth_column( array $columns ): array { */ public function render_depth_cell( string $column, mixed $post_or_order ): void { $node = 'dws_lo_depth' === $column ? dws_lowc_get_order_node( $post_or_order ) : null; - if ( null === $node ) { + if ( \is_null( $node ) ) { return; } @@ -158,7 +157,7 @@ public function render_depth_cell( string $column, mixed $post_or_order ): void */ public function render_filters( string $type ): void { $post_type = $this->can_see() && \in_array( $type, dws_lowc_get_supported_order_types(), true ) ? get_post_type_object( $type ) : null; - if ( null === $post_type ) { + if ( \is_null( $post_type ) ) { return; } @@ -209,7 +208,7 @@ public function filter_posts_query( array $query_vars ): array { $query_vars['meta_query'] = self::add_meta_clauses( $query_vars['meta_query'] ?? null, $clauses ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- The filters are meta values. } $tree = self::get_tree_filter( $query_vars['post__in'] ?? null ); - if ( null !== $tree ) { + if ( ! \is_null( $tree ) ) { $query_vars['post__in'] = $tree; } @@ -242,7 +241,7 @@ public function filter_orders_query( array $args ): array { $args['meta_query'] = self::add_meta_clauses( $args['meta_query'] ?? null, $clauses ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- The filters are meta values. } $tree = self::get_tree_filter( $args['id'] ?? null ); - if ( null !== $tree ) { + if ( ! \is_null( $tree ) ) { $args['id'] = $tree; } @@ -262,7 +261,7 @@ public function filter_orders_query( array $args ): array { */ public function add_row_actions( array $actions, \WC_Order $order ): array { $node = $this->can_see() ? dws_lowc_get_order_node( $order ) : null; - if ( null === $node ) { + if ( \is_null( $node ) ) { return $actions; } @@ -309,7 +308,7 @@ public function add_row_actions( array $actions, \WC_Order $order ): array { * @return void */ public function enqueue_assets(): void { - if ( ! $this->can_see() || null === self::get_list_type( get_current_screen() ) ) { + if ( ! $this->can_see() || \is_null( self::get_list_type( get_current_screen() ) ) ) { return; } @@ -350,7 +349,7 @@ protected function can_see(): bool { */ protected static function get_list_type( ?\WP_Screen $screen ): ?string { foreach ( dws_lowc_get_supported_order_types() as $type ) { - if ( null !== $screen && ( OrderUtil::custom_orders_table_usage_is_enabled() ? wc_get_page_screen_id( $type ) : "edit-$type" ) === $screen->id ) { + if ( ! \is_null( $screen ) && ( OrderUtil::custom_orders_table_usage_is_enabled() ? wc_get_page_screen_id( $type ) : "edit-$type" ) === $screen->id ) { return $type; } } @@ -382,7 +381,6 @@ protected static function get_deepest_level( string $type ): int { ) ); $stored = \is_array( $deepest ) && array() !== $deepest ? dws_lowc_get_order_node( \reset( $deepest ) )?->get_depth() : null; - return \max( dws_lowc_get_max_depth(), $stored ?? 0 ); } @@ -396,7 +394,6 @@ protected static function get_deepest_level( string $type ): int { */ protected static function get_depth_filter(): ?int { $depth = \filter_var( self::get_query_arg( '_dws_lo_depth' ), \FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 0 ) ) ); - return false === $depth ? null : $depth; } @@ -410,7 +407,7 @@ protected static function get_depth_filter(): ?int { */ protected static function get_depth_clauses(): array { $depth = self::get_depth_filter(); - if ( null === $depth ) { + if ( \is_null( $depth ) ) { return array(); } @@ -418,7 +415,6 @@ protected static function get_depth_clauses(): array { 'key' => '_dws_lo_depth', 'value' => (string) $depth, ); - return array( 0 === $depth ? array( 'relation' => 'OR', @@ -463,7 +459,7 @@ protected static function get_tree_filter( mixed $found ): ?array { } $root = dws_lowc_get_root_order( $pivot ); - $tree = null === $root ? array() : dws_lowc_get_orders_tree( $root->get_order() ) ?? array(); + $tree = \is_null( $root ) ? array() : dws_lowc_get_orders_tree( $root->get_order() ) ?? array(); $found = \is_array( $found ) ? \filter_var( $found, \FILTER_VALIDATE_INT, \FILTER_REQUIRE_ARRAY | \FILTER_NULL_ON_FAILURE ) : null; if ( \is_array( $found ) && array() !== $found ) { $tree = \array_intersect( $tree, $found ); diff --git a/plugins/linked-orders-for-woocommerce/src/MetaBox.php b/plugins/linked-orders-for-woocommerce/src/MetaBox.php index 41e88c7..0ec43e0 100644 --- a/plugins/linked-orders-for-woocommerce/src/MetaBox.php +++ b/plugins/linked-orders-for-woocommerce/src/MetaBox.php @@ -43,7 +43,7 @@ public function register_hooks(): void { */ public function add_meta_box( string $screen_id, mixed $post_or_order ): void { $node = current_user_can( DWS_LOWC_CAP_SEE_ORDER_LINKS ) && current_user_can( DWS_LOWC_CAP_SEE_METABOX ) ? dws_lowc_get_order_node( $post_or_order ) : null; - if ( null === $node ) { + if ( \is_null( $node ) ) { return; } @@ -63,14 +63,14 @@ public function add_meta_box( string $screen_id, mixed $post_or_order ): void { */ public function render_meta_box( mixed $post_or_order ): void { $node = dws_lowc_get_order_node( $post_or_order ); - if ( null === $node ) { + if ( \is_null( $node ) ) { return; } $parent = $node->get_parent(); $can_create = $node->can_create_child(); $type_name = dws_lowc_get_singular_name( $node->get_post_type() ); - if ( null !== $parent ) { + if ( ! \is_null( $parent ) ) { ?>
    : diff --git a/plugins/linked-orders-for-woocommerce/src/Plugin.php b/plugins/linked-orders-for-woocommerce/src/Plugin.php index 8168c4e..a3e8941 100644 --- a/plugins/linked-orders-for-woocommerce/src/Plugin.php +++ b/plugins/linked-orders-for-woocommerce/src/Plugin.php @@ -17,7 +17,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Composes the plugin from its logger, scheduler, migrations and components, inside the framework's boot boundary. + * Composes the plugin inside the framework's boot boundary. * * @since 2.0.0 * @version 2.0.0 @@ -73,7 +73,7 @@ final class Plugin { * * @var PluginBoot */ - public readonly PluginBoot $boot; + public readonly PluginBoot $boundary; /** * The scheduler, which registers its WP-Cron schedules even while the plugin is blocked. @@ -128,7 +128,7 @@ final class Plugin { public function __construct() { $this->logger = new WooCommerceLogger( self::SLUG, \defined( 'DWS_LOWC_DEBUG' ) && true === \constant( 'DWS_LOWC_DEBUG' ) ? 'debug' : null ); $this->scheduler = new Scheduler( self::PREFIX ); - $this->boot = new PluginBoot( DWS_LOWC_FILE, self::PREFIX, $this->logger ); + $this->boundary = new PluginBoot( DWS_LOWC_FILE, self::PREFIX, $this->logger ); } // endregion @@ -144,7 +144,7 @@ public function __construct() { * @return void */ public function boot(): void { - $this->boot->run( $this->compose( ... ) ); + $this->boundary->run( $this->compose( ... ) ); } /** @@ -196,7 +196,6 @@ protected function compose(): bool { $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); - return true; } diff --git a/plugins/linked-orders-for-woocommerce/src/Settings.php b/plugins/linked-orders-for-woocommerce/src/Settings.php index f14647d..f433b96 100644 --- a/plugins/linked-orders-for-woocommerce/src/Settings.php +++ b/plugins/linked-orders-for-woocommerce/src/Settings.php @@ -71,7 +71,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array $wc_fields = array(); foreach ( $group_fields as $id => $field ) { - $wc_fields[] = array( 'id' => "dws-lowc_{$group}_{$id}" ) + $field; + $wc_fields[] = array( 'id' => "dws-lowc_{$group}_$id" ) + $field; } /** @@ -89,7 +89,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array array( array( 'type' => 'title', - 'id' => "dws-lowc_{$group}", + 'id' => "dws-lowc_$group", 'title' => 'general' === $group ? __( 'General Settings', 'linked-orders-for-woocommerce' ) : __( 'Plugin Settings', 'linked-orders-for-woocommerce' ), ), ), @@ -97,7 +97,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array array( array( 'type' => 'sectionend', - 'id' => "dws-lowc_{$group}", + 'id' => "dws-lowc_$group", ), ) ); @@ -145,7 +145,6 @@ public function register_section( SettingsSectionRegistry $registry ): void { */ public function add_settings_link( array $links ): array { \array_unshift( $links, \sprintf( '%s', esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=' . self::SECTION ) ), esc_html__( 'Settings', 'linked-orders-for-woocommerce' ) ) ); - return $links; } diff --git a/plugins/linked-orders-for-woocommerce/stylelint.config.js b/plugins/linked-orders-for-woocommerce/stylelint.config.js index 84c3761..23802a6 100644 --- a/plugins/linked-orders-for-woocommerce/stylelint.config.js +++ b/plugins/linked-orders-for-woocommerce/stylelint.config.js @@ -1,3 +1 @@ -const baseConfig = require( '@ahegyes/wordpress-configs/node/stylelint.config.base.js' ); - -module.exports = { ...baseConfig }; +module.exports = require( '@ahegyes/wordpress-configs/node/stylelint.config.base.js' ); diff --git a/plugins/linked-orders-for-woocommerce/tests/EndToEnd/cli.ts b/plugins/linked-orders-for-woocommerce/tests/EndToEnd/cli.ts new file mode 100644 index 0000000..14aabda --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/tests/EndToEnd/cli.ts @@ -0,0 +1,13 @@ +/// +import { execFileSync } from 'node:child_process'; + +// No screen shows some of what the specs set up or check, so they run WP-CLI on the tests site and read its last line. +export const cli = ( ...args: string[] ): string => + execFileSync( + 'node_modules/.bin/wp-env', + [ '--config', '.wp-env.tests.json', 'run', 'cli', 'wp', ...args ], + { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] } + ) + .trim() + .split( '\n' ) + .pop() ?? ''; diff --git a/plugins/linked-orders-for-woocommerce/tests/EndToEnd/without-woocommerce.spec.ts b/plugins/linked-orders-for-woocommerce/tests/EndToEnd/without-woocommerce.spec.ts index 8d95c9b..cdce20f 100644 --- a/plugins/linked-orders-for-woocommerce/tests/EndToEnd/without-woocommerce.spec.ts +++ b/plugins/linked-orders-for-woocommerce/tests/EndToEnd/without-woocommerce.spec.ts @@ -1,30 +1,10 @@ -/// -import { execFileSync } from 'node:child_process'; import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +import { cli } from './cli'; // WordPress core's own message for a plugin whose Requires Plugins header lists an inactive plugin. const DEPENDENCY = 'Linked Orders for WooCommerce requires 1 plugin to be installed and activated: WooCommerce.'; -// No page shows the boot's outcome, so the site's WP-CLI reads it. -const bootStatus = (): string => - execFileSync( - 'node_modules/.bin/wp-env', - [ - '--config', - '.wp-env.tests.json', - 'run', - 'cli', - 'wp', - 'eval', - 'echo dws_lowc_plugin()->boot->status;', - ], - { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] } - ) - .trim() - .split( '\n' ) - .pop() ?? ''; - test.describe( 'Without WooCommerce', () => { test.beforeAll( async ( { requestUtils } ) => { await requestUtils.deactivatePlugin( 'woocommerce' ); @@ -37,7 +17,9 @@ test.describe( 'Without WooCommerce', () => { test( 'the plugin stays blocked, WordPress names the missing plugin and every screen still loads', async ( { page, } ) => { - expect( bootStatus() ).toBe( 'blocked' ); + expect( + cli( 'eval', 'echo dws_lowc_plugin()->boundary->status;' ) + ).toBe( 'blocked' ); const front = await page.goto( '/' ); expect( front?.status() ).toBe( 200 ); diff --git a/plugins/linked-orders-for-woocommerce/tests/Fixtures/sequential-order-numbers-pro.php b/plugins/linked-orders-for-woocommerce/tests/Fixtures/sequential-order-numbers-pro.php index 96af337..a4e3f4b 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Fixtures/sequential-order-numbers-pro.php +++ b/plugins/linked-orders-for-woocommerce/tests/Fixtures/sequential-order-numbers-pro.php @@ -15,6 +15,5 @@ function wc_seq_order_number_pro(): WC_Seq_Order_Number_Pro { static $plugin = null; $plugin ??= new WC_Seq_Order_Number_Pro(); - return $plugin; } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/AssetsTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/AssetsTest.php index 93d33fc..dc59eb4 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/AssetsTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/AssetsTest.php @@ -34,12 +34,12 @@ public function test_a_script_registers_with_its_generated_dependencies_and_vers } public function test_a_stylesheet_registers_with_its_generated_version_and_its_right_to_left_variant(): void { - self::assertTrue( dws_lowc_register_style( self::HANDLE, 'tests/Fixtures/built.css', 'print' ) ); + self::assertTrue( dws_lowc_register_style( self::HANDLE, 'tests/Fixtures/built.css' ) ); $style = wp_styles()->registered[ self::HANDLE ]; self::assertSame( plugins_url( 'tests/Fixtures/built.css', DWS_LOWC_FILE ), $style->src ); self::assertSame( 'fixture', $style->ver ); - self::assertSame( 'print', $style->args ); + self::assertSame( 'all', $style->args ); self::assertSame( 'replace', $style->extra['rtl'] ?? null ); } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php index 54f83f5..5f15d14 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php @@ -2,15 +2,17 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; +use DeepWebSolutions\LinkedOrders\Autocompletion; +use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( Autocompletion::class )] final class AutocompletionTest extends TestCase { - use OrderStorage; + use OrderStorageTrait; protected mixed $stored = false; @@ -48,7 +50,6 @@ public function test_the_completed_order_passes_the_filters_before_its_descendan $seen = array(); $record = static function ( bool $should, int $order_id ) use ( &$seen ): bool { $seen[] = $order_id; - return $should; }; add_filter( 'dws_lowc/autocompletion/should_autocomplete', $record, 10, 2 ); @@ -111,7 +112,6 @@ protected function create_tree(): array { $grandchild = $this->create_order(); self::assertTrue( dws_lowc_link_orders( $root->get_id(), $child->get_id() ) ); self::assertTrue( dws_lowc_link_orders( $child->get_id(), $grandchild->get_id() ) ); - return array( wc_get_order( $root->get_id() ), wc_get_order( $child->get_id() ), wc_get_order( $grandchild->get_id() ) ); } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php index f402037..6dbe6dc 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php @@ -2,20 +2,22 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; +use DeepWebSolutions\LinkedOrders\ChildCreation; use DeepWebSolutions\LinkedOrders\Plugin; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\Notice; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\NoticeScope; use DeepWebSolutions\LinkedOrders\Scoped\Psr\Log\NullLogger; +use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( ChildCreation::class )] final class ChildCreationTest extends TestCase { - use OrderStorage; + use OrderStorageTrait; protected const string ACTION = 'admin_post_dws_lowc_create_linked_child'; diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php index ad5fa65..2356ded 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php @@ -2,15 +2,15 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; -use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\Before; +use DeepWebSolutions\LinkedOrders\Tests\Support\RecordsDeprecationsTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversNothing] final class DeprecatedApiTest extends TestCase { - protected array $deprecated = array(); + use RecordsDeprecationsTrait; + public static function hook_tags(): array { return array( @@ -25,26 +25,6 @@ public static function hook_tags(): array { ); } - #[Before] - protected function record_deprecations(): void { - add_filter( 'deprecated_function_trigger_error', '__return_false' ); - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_function_run', array( $this, 'record' ), 10, 2 ); - add_action( 'deprecated_hook_run', array( $this, 'record' ), 10, 2 ); - } - - #[After] - protected function stop_recording(): void { - remove_filter( 'deprecated_function_trigger_error', '__return_false' ); - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_function_run', array( $this, 'record' ) ); - remove_action( 'deprecated_hook_run', array( $this, 'record' ) ); - } - - public function record( string $name, string $replacement ): void { - $this->deprecated[] = array( $name, $replacement ); - } - public function test_the_v1_getters_answer_from_the_plugin_header(): void { $header = get_file_data( DWS_LOWC_FILE, @@ -95,13 +75,16 @@ public function test_a_v1_hook_tag_builds_as_v1_built_it( string $shim, array $a self::assertSame( array( array( $shim, '' ) ), $this->deprecated ); } - public function test_the_v1_boot_hooks_fire_after_the_v2_ones_with_the_failure(): void { + // The v1 hooks fire at the earliest priority, so every v1 listener runs before a v2 listener of any later one. + public function test_the_v1_boot_hooks_fire_at_the_earliest_priority_with_the_failure(): void { $fired = array(); $spy = static function ( mixed $error = null ) use ( &$fired ): void { $fired[] = array( current_action(), $error ); }; add_action( 'dws_lowc_initialized', $spy ); add_action( 'dws_lowc_initialization_failure', $spy ); + add_action( 'dws_lowc/boot/succeeded', $spy, PHP_INT_MIN + 1 ); + add_action( 'dws_lowc/boot/failed', $spy, PHP_INT_MIN + 1 ); $error = new \RuntimeException( 'Failed.' ); try { @@ -110,12 +93,16 @@ public function test_the_v1_boot_hooks_fire_after_the_v2_ones_with_the_failure() } finally { remove_action( 'dws_lowc_initialized', $spy ); remove_action( 'dws_lowc_initialization_failure', $spy ); + remove_action( 'dws_lowc/boot/succeeded', $spy, PHP_INT_MIN + 1 ); + remove_action( 'dws_lowc/boot/failed', $spy, PHP_INT_MIN + 1 ); } self::assertSame( array( array( 'dws_lowc_initialized', null ), + array( 'dws_lowc/boot/succeeded', '' ), array( 'dws_lowc_initialization_failure', $error ), + array( 'dws_lowc/boot/failed', $error ), ), $fired ); @@ -126,5 +113,7 @@ public function test_the_v1_boot_hooks_fire_after_the_v2_ones_with_the_failure() ), $this->deprecated ); + self::assertSame( PHP_INT_MIN, has_action( 'dws_lowc/boot/succeeded', 'dws_lowc_fire_v1_initialized' ) ); + self::assertSame( PHP_INT_MIN, has_action( 'dws_lowc/boot/failed', 'dws_lowc_fire_v1_initialization_failure' ) ); } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedHooksTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedHooksTest.php index e346bd0..575d335 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedHooksTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedHooksTest.php @@ -2,6 +2,8 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; +use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; +use DeepWebSolutions\LinkedOrders\Tests\Support\RecordsDeprecationsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; @@ -10,9 +12,8 @@ #[CoversNothing] final class DeprecatedHooksTest extends TestCase { - use OrderStorage; - - protected array $deprecated = array(); + use OrderStorageTrait; + use RecordsDeprecationsTrait; public static function renamed_hooks(): array { return array( @@ -32,24 +33,16 @@ public static function renamed_hooks(): array { } #[Before] - protected function record_deprecations(): void { + protected function log_in(): void { wp_set_current_user( 1 ); - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ), 10, 2 ); } #[After] - protected function stop_recording(): void { + protected function reset_the_screen(): void { global $wp_meta_boxes; - $wp_meta_boxes = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Resets what the rows registered. + $wp_meta_boxes = array(); unset( $GLOBALS['current_screen'] ); wp_set_current_user( 0 ); - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ) ); - } - - public function record_deprecated_hook( string $hook, string $replacement ): void { - $this->deprecated[] = array( $hook, $replacement ); } #[DataProvider( 'renamed_hooks' )] @@ -58,7 +51,6 @@ public function test_a_renamed_hook_fires_its_v1_literal_then_its_v2_name( strin $fired = array(); $spy = static function ( mixed $value = null ) use ( &$fired ): mixed { $fired[] = current_filter(); - return $value; }; add_filter( $v1, $spy ); @@ -144,7 +136,6 @@ protected function open_list(): string { require_once ABSPATH . 'wp-admin/includes/screen.php'; $screen = wc_get_page_screen_id( 'shop-order' ); set_current_screen( $screen ); - return $screen; } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/Integration/SequentialOrderNumbersProTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/Integration/SequentialOrderNumbersProTest.php index 6836cd1..cb71e22 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/Integration/SequentialOrderNumbersProTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/Integration/SequentialOrderNumbersProTest.php @@ -3,14 +3,14 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration\Integration; use DeepWebSolutions\LinkedOrders\Integration\SequentialOrderNumbersPro; -use DeepWebSolutions\LinkedOrders\Tests\Integration\OrderStorage; -use PHPUnit\Framework\Attributes\CoversNothing; +use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( SequentialOrderNumbersPro::class )] final class SequentialOrderNumbersProTest extends TestCase { - use OrderStorage; + use OrderStorageTrait; public function test_the_integration_loads_only_beside_the_plugin(): void { self::assertFalse( SequentialOrderNumbersPro::should_load() ); @@ -21,7 +21,7 @@ public function test_the_integration_loads_only_beside_the_plugin(): void { public function test_a_linked_child_is_numbered_before_it_is_saved(): void { require_once \dirname( __DIR__, 2 ) . '/Fixtures/sequential-order-numbers-pro.php'; self::assertTrue( SequentialOrderNumbersPro::should_load() ); - ( new SequentialOrderNumbersPro() )->register_hooks(); + new SequentialOrderNumbersPro()->register_hooks(); wp_set_current_user( 1 ); $id = dws_lowc_create_linked_child( $this->create_order()->get_id() ); diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php index 8b8aed7..709b183 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php @@ -4,6 +4,8 @@ use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Storage\Lock; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Storage\LockClaim; +use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; +use DeepWebSolutions\LinkedOrders\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; @@ -12,7 +14,8 @@ #[CoversNothing] final class LinkingTest extends TestCase { - use OrderStorage; + use OrderStorageTrait; + use RunsRequestsTrait; protected int $user = 0; @@ -55,7 +58,7 @@ public function test_a_link_stores_the_childs_parent_and_depth_and_the_parents_c // The parent's children are one meta value that every link reads and rewrites, so a link must not write back a list another request changed after this one loaded the parent. #[DataProvider( 'storages_and_data_caching' )] public function test_a_link_keeps_a_child_another_request_linked_after_the_parent_was_loaded( bool $hpos, bool $sync, bool $caching = false ): void { - $this->use_storage( $hpos, $sync, $caching ); + $this->use_storage_and_data_caching( $hpos, $sync, $caching ); $parent = $this->create_order(); $first = $this->create_order(); $second = $this->create_order(); @@ -74,7 +77,7 @@ public function test_a_link_keeps_a_child_another_request_linked_after_the_paren // A child another request linked after this one loaded it must be refused, so the child's meta is read past the caches too. #[DataProvider( 'storages_and_data_caching' )] public function test_a_link_is_refused_for_a_child_another_request_linked_after_the_child_was_loaded( bool $hpos, bool $sync, bool $caching = false ): void { - $this->use_storage( $hpos, $sync, $caching ); + $this->use_storage_and_data_caching( $hpos, $sync, $caching ); $parent = $this->create_order(); $other = $this->create_order(); $child = $this->create_order(); @@ -95,7 +98,7 @@ public function test_a_link_waits_for_another_request_to_finish_linking(): void $php = '$lock = new DeepWebSolutions\\LinkedOrders\\Scoped\\DeepWebSolutions\\Framework\\Core\\Storage\\Lock( "dws_lowc_linking", 30 ); $lock->acquire( "another-request" ); sleep( 1 ); $lock->release( "another-request" );'; \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . ' eval ' . \escapeshellarg( $php ) . ' > /dev/null 2>&1 &' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- The other request must hold the lock while this one links. - $held = static fn (): bool => null !== $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = 'dws_lowc_linking'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Watches the other request's lock row past the options cache. + $held = static fn (): bool => ! \is_null( $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = 'dws_lowc_linking'" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Watches the other request's lock row past the options cache. $tries = 0; while ( ! $held() && $tries++ < 300 ) { \usleep( 50000 ); @@ -369,12 +372,21 @@ public static function storages_and_data_caching(): array { return self::storages() + array( 'hpos-with-data-caching' => array( true, false, true ) ); } + // The trait keeps the order tables' data caching off, which these rows also need on. + protected function use_storage_and_data_caching( bool $hpos, bool $sync, bool $caching ): void { + $this->use_storage( $hpos, $sync ); + $hook = 'pre_option_woocommerce_hpos_datastore_caching_enabled'; + remove_filter( $hook, $this->storage_filters[ $hook ] ); + $this->storage_filters[ $hook ] = static fn (): string => $caching ? 'yes' : 'no'; + add_filter( $hook, $this->storage_filters[ $hook ] ); + } + // The other request runs on the row's storage, as the shared tests site stores orders on the order tables. protected function link_in_another_request( bool $hpos, bool $sync, int $parent_id, int $child_id ): void { - $php = \sprintf( 'add_filter( "pre_option_woocommerce_custom_orders_table_enabled", fn () => "%s" ); add_filter( "pre_option_woocommerce_custom_orders_table_data_sync_enabled", fn () => "%s" ); echo var_export( dws_lowc_link_orders( %d, %d ), true );', $hpos ? 'yes' : 'no', $sync ? 'yes' : 'no', $parent_id, $child_id ); - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . ' eval ' . \escapeshellarg( $php ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- The race needs a second request. + $php = \sprintf( 'add_filter( "pre_option_woocommerce_custom_orders_table_enabled", fn () => "%s" ); add_filter( "pre_option_woocommerce_custom_orders_table_data_sync_enabled", fn () => "%s" ); echo var_export( dws_lowc_link_orders( %d, %d ), true );', $hpos ? 'yes' : 'no', $sync ? 'yes' : 'no', $parent_id, $child_id ); + $request = self::request( $php ); - self::assertSame( 0, $status, \implode( "\n", $output ) ); - self::assertSame( 'true', (string) \end( $output ) ); + self::assertSame( 0, $request['status'], $request['output'] ); + self::assertSame( 'true', $request['last'], $request['output'] ); } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php index 10d8a6b..244f189 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php @@ -3,17 +3,19 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; use Automattic\WooCommerce\Utilities\OrderUtil; +use DeepWebSolutions\LinkedOrders\ListTable; use DeepWebSolutions\LinkedOrders\Plugin; +use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\BeforeClass; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( ListTable::class )] final class ListTableTest extends TestCase { - use OrderStorage; + use OrderStorageTrait; protected const array COLUMNS = array( 'cb' => '', @@ -285,8 +287,7 @@ public function test_the_list_loads_the_script_and_the_icons_only_on_order_lists protected function open_list(): string { $screen = OrderUtil::custom_orders_table_usage_is_enabled() ? wc_get_page_screen_id( 'shop-order' ) : 'edit-shop_order'; set_current_screen( $screen ); - $GLOBALS['typenow'] = 'shop_order'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The posts list sets it from the request. - + $GLOBALS['typenow'] = 'shop_order'; return $screen; } @@ -314,11 +315,11 @@ protected function render_filters(): string { // The found ids stand in for the orders a search narrowed the list to, under the key each storage's search sets. protected function query( ?array $found = null, array $meta_query = array() ): array { - $others = array() === $meta_query ? array() : array( 'meta_query' => $meta_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Another extension's filter. + $others = array() === $meta_query ? array() : array( 'meta_query' => $meta_query ); if ( OrderUtil::custom_orders_table_usage_is_enabled() ) { $args = apply_filters( 'woocommerce_order_list_table_prepare_items_query_args', - $others + ( null === $found ? array() : array( 'id' => $found ) ) + array( + $others + ( \is_null( $found ) ? array() : array( 'id' => $found ) ) + array( 'type' => 'shop_order', 'status' => 'any', 'limit' => -1, @@ -327,13 +328,12 @@ protected function query( ?array $found = null, array $meta_query = array() ): a 'order' => 'ASC', ) ); - return \array_map( 'intval', wc_get_orders( $args ) ); } $vars = apply_filters( 'request', - $others + ( null === $found ? array() : array( 'post__in' => $found ) ) + array( + $others + ( \is_null( $found ) ? array() : array( 'post__in' => $found ) ) + array( 'post_type' => 'shop_order', 'post_status' => 'any', 'posts_per_page' => -1, @@ -342,7 +342,6 @@ protected function query( ?array $found = null, array $meta_query = array() ): a 'order' => 'ASC', ) ); - - return \array_map( 'intval', ( new \WP_Query( $vars ) )->posts ); + return \array_map( 'intval', new \WP_Query( $vars )->posts ); } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php index b68da96..e79f1b4 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php @@ -3,16 +3,18 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; use Automattic\WooCommerce\Utilities\OrderUtil; +use DeepWebSolutions\LinkedOrders\MetaBox; +use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\BeforeClass; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( MetaBox::class )] final class MetaBoxTest extends TestCase { - use OrderStorage; + use OrderStorageTrait; protected int $user = 0; @@ -33,7 +35,7 @@ protected function log_in(): void { #[After] protected function clear_meta_boxes(): void { global $wp_meta_boxes; - $wp_meta_boxes = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Resets what the rows registered. + $wp_meta_boxes = array(); wp_set_current_user( 0 ); if ( 0 !== $this->user ) { require_once ABSPATH . 'wp-admin/includes/user.php'; @@ -101,7 +103,7 @@ public function test_the_box_shows_the_parent_the_children_and_the_button_to_add self::assertSame( wc_get_order( $child->get_id() )->get_edit_order_url(), $link?->getAttribute( 'href' ) ); $button = $box->querySelector( 'a.button' ); self::assertSame( 'Add new child Order', \trim( (string) $button?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - \parse_str( (string) \wp_parse_url( (string) $button?->getAttribute( 'href' ), \PHP_URL_QUERY ), $query ); + \parse_str( (string) wp_parse_url( (string) $button?->getAttribute( 'href' ), \PHP_URL_QUERY ), $query ); self::assertSame( 'dws_lowc_create_linked_child', $query['action'] ?? null ); self::assertSame( (string) $order->get_id(), $query['parent_id'] ?? null ); self::assertSame( 1, wp_verify_nonce( (string) ( $query['_wpnonce'] ?? '' ), 'dws_create_linked_child' ) ); @@ -129,7 +131,6 @@ protected function register( \WC_Order|\WP_Post $post_or_order, ?string $screen self::assertInstanceOf( \WP_Post::class, $post_or_order ); } do_action( 'add_meta_boxes', $screen, $post_or_order ); - return $wp_meta_boxes[ $screen ]['side']['default']['dws-linked-orders'] ?? null; } @@ -141,7 +142,6 @@ protected function render( int $order_id ): \Dom\HTMLDocument { \ob_start(); \call_user_func( $box['callback'], $order, $box ); - return \Dom\HTMLDocument::createFromString( '' . \ob_get_clean() . '', \LIBXML_NOERROR ); } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/MetadataTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/MetadataTest.php index 5580493..0f66a61 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/MetadataTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/MetadataTest.php @@ -51,7 +51,6 @@ protected static function get_listed_header( string $header ): string { protected static function translate_name(): \Closure { $name = self::get_listed_header( 'Name' ); - return static fn ( string $translation, string $text ): string => $name === $text ? self::TRANSLATION : $translation; } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/MigrationsTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/MigrationsTest.php index 844a7e0..877dfcf 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/MigrationsTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/MigrationsTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\LinkedOrders\Plugin; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; +use DeepWebSolutions\LinkedOrders\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -11,23 +12,12 @@ #[CoversNothing] final class MigrationsTest extends TestCase { - protected const array KEYS = array( DWS_LOWC_OPTION_MAX_DEPTH, DWS_LOWC_OPTION_AUTOCOMPLETE, DWS_LOWC_OPTION_DELETE_DATA ); + use RestoresOptionsTrait; - protected array $stored = array(); - - #[\Override] - protected function setUp(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - } + protected const array OPTIONS = array( DWS_LOWC_OPTION_MAX_DEPTH, DWS_LOWC_OPTION_AUTOCOMPLETE, DWS_LOWC_OPTION_DELETE_DATA ); #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - + protected function restore_the_revision(): void { update_option( Plugin::PREFIX . '_revision', \count( dws_lowc_get_migration_steps() ) ); } @@ -35,7 +25,7 @@ protected function restore_rows(): void { #[RunInSeparateProcess] public function test_the_runner_brings_a_new_site_to_revision_one_with_the_untranslated_defaults_autoloaded(): void { global $wpdb; - foreach ( self::KEYS as $key ) { + foreach ( self::OPTIONS as $key ) { delete_option( $key ); } update_option( Plugin::PREFIX . '_revision', 0 ); @@ -46,7 +36,7 @@ public function test_the_runner_brings_a_new_site_to_revision_one_with_the_untra self::assertSame( '1', get_option( DWS_LOWC_OPTION_MAX_DEPTH ) ); self::assertSame( 'no', get_option( DWS_LOWC_OPTION_AUTOCOMPLETE ) ); self::assertSame( 'no', get_option( DWS_LOWC_OPTION_DELETE_DATA ) ); - foreach ( self::KEYS as $key ) { + foreach ( self::OPTIONS as $key ) { self::assertSame( 'on', $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", $key ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads the stored autoload flag. } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/MultisiteTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/MultisiteTest.php new file mode 100644 index 0000000..223971c --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/MultisiteTest.php @@ -0,0 +1,41 @@ + get_network()?->domain, + 'path' => get_network()?->path . \str_replace( '_', '-', Plugin::PREFIX ) . '-second/', + ) + ); + self::assertIsInt( $site_id ); + + try { + // WooCommerce queries two of its tables during a new site's first init, before its version check installs them, so a request without the plugin installs WooCommerce first. + $installed = self::request_provoking( 'WordPress database error', 'echo (int) function_exists( "dws_lowc_boot" );', '--url=' . get_home_url( $site_id, '/' ), '--skip-plugins=' . Plugin::SLUG ); + self::assertSame( 0, $installed['status'], $installed['output'] ); + self::assertSame( '0', $installed['last'], $installed['output'] ); + + $request = self::request( 'echo did_action( "dws_lowc/boot/succeeded" );', '--url=' . get_home_url( $site_id, '/' ) ); + + self::assertSame( '1', $request['last'], $request['output'] ); + self::assertSame( 'no', get_blog_option( $site_id, DWS_LOWC_OPTION_DELETE_DATA ) ); + self::assertTrue( update_blog_option( $site_id, DWS_LOWC_OPTION_DELETE_DATA, 'yes' ) ); + self::assertSame( 'no', get_option( DWS_LOWC_OPTION_DELETE_DATA ) ); + } finally { + wp_delete_site( $site_id ); + } + } +} diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/OptionsTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/OptionsTest.php index 1353ca7..4aae309 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/OptionsTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/OptionsTest.php @@ -2,17 +2,16 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; -use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\Before; +use DeepWebSolutions\LinkedOrders\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversNothing] final class OptionsTest extends TestCase { - protected const array RESTORED = array( DWS_LOWC_OPTION_MAX_DEPTH, DWS_LOWC_OPTION_AUTOCOMPLETE, DWS_LOWC_OPTION_DELETE_DATA ); + use RestoresOptionsTrait; - protected array $stored = array(); + protected const array OPTIONS = array( DWS_LOWC_OPTION_MAX_DEPTH, DWS_LOWC_OPTION_AUTOCOMPLETE, DWS_LOWC_OPTION_DELETE_DATA ); public static function stored_depths(): array { return array( @@ -36,20 +35,6 @@ public static function submitted_depths(): array { ); } - #[Before] - protected function store_rows(): void { - foreach ( self::RESTORED as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - } - - #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - } - public function test_the_settings_keep_the_keys_v1_stored_them_under(): void { self::assertSame( 'dws-lowc_general_max-depth', DWS_LOWC_OPTION_MAX_DEPTH ); self::assertSame( 'dws-lowc_general_autocomplete-descendants', DWS_LOWC_OPTION_AUTOCOMPLETE ); @@ -57,7 +42,7 @@ public function test_the_settings_keep_the_keys_v1_stored_them_under(): void { } public function test_a_missing_depth_reads_as_v1s_default(): void { - foreach ( self::RESTORED as $key ) { + foreach ( self::OPTIONS as $key ) { delete_option( $key ); } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/PartialDeployTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/PartialDeployTest.php index 5bc6c56..1682954 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/PartialDeployTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/PartialDeployTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; +use DeepWebSolutions\LinkedOrders\Plugin; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use DeepWebSolutions\LinkedOrders\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\CoversNothing; @@ -29,7 +30,7 @@ public function test_an_include_that_throws_shows_the_build_notice_and_boots_not $include = \dirname( DWS_LOWC_FILE ) . '/includes/zz-throws.php'; \file_put_contents( $include, "boot->status ); + self::assertSame( 'booted', $plugin->boundary->status ); self::assertSame( array( SequentialOrderNumbersPro::class ), $plugin->kernel->skipped ); } @@ -35,6 +35,19 @@ public function test_the_entry_loads_every_include_but_those_whose_names_start_w self::assertSame( 'skipped', $request['last'], $request['output'] ); } + public function test_a_boot_listener_that_throws_is_not_reported_as_an_incomplete_build(): void { + $include = \dirname( DWS_LOWC_FILE ) . '/includes/zz-boot-listener.php'; + \file_put_contents( $include, "stored[ $key ] = get_option( $key ); - } - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ), 10, 2 ); - } - - #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ) ); - } - - public function record_deprecated_hook( string $hook, string $replacement ): void { - $this->deprecated[] = array( $hook, $replacement ); - } - #[DataProvider( 'stored_settings' )] public function test_a_setting_reads_validated_by_its_v1_field_and_group( string $key, string $stored, string $field_id, ?string $group, int|bool $expected ): void { update_option( $key, $stored ); @@ -59,7 +36,7 @@ public function test_a_setting_reads_validated_by_its_v1_field_and_group( string } public function test_missing_settings_read_as_v1_defaults(): void { - foreach ( self::KEYS as $key ) { + foreach ( self::OPTIONS as $key ) { delete_option( $key ); } @@ -128,18 +105,15 @@ public function test_a_validated_value_passes_the_v1_filters_of_its_path_then_th $filters = array( 'linked_orders_for_woocommerce/general_settings/get_validated_option_value' => static function ( mixed $value, string $field_id ) use ( &$seen ): mixed { $seen[] = array( 'group', $value, $field_id ); - return 4; }, 'linked_orders_for_woocommerce/settings/get_validated_option_value' => static function ( mixed $value, string $field_id ) use ( &$seen ): mixed { $seen[] = array( 'section', $value, $field_id ); - return 5; }, ); $v2 = static function ( mixed $value, string $field_id, string $group ) use ( &$seen ): mixed { $seen[] = array( 'v2', $value, $field_id, $group ); - return 6; }; foreach ( $filters as $hook => $filter ) { diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php index 5b73310..91a39ef 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php @@ -3,24 +3,24 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; use DeepWebSolutions\LinkedOrders\Settings; +use DeepWebSolutions\LinkedOrders\Tests\Support\RecordsDeprecationsTrait; +use DeepWebSolutions\LinkedOrders\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( Settings::class )] final class SettingsTest extends TestCase { + use RecordsDeprecationsTrait; + use RestoresOptionsTrait; + protected const string SECTION = 'dws-linked-orders'; - protected const array KEYS = array( DWS_LOWC_OPTION_MAX_DEPTH, DWS_LOWC_OPTION_AUTOCOMPLETE, DWS_LOWC_OPTION_DELETE_DATA ); + protected const array OPTIONS = array( DWS_LOWC_OPTION_MAX_DEPTH, DWS_LOWC_OPTION_AUTOCOMPLETE, DWS_LOWC_OPTION_DELETE_DATA ); protected static \WC_Settings_Page $advanced; - protected array $stored = array(); - - protected array $deprecated = array(); - #[\Override] public static function setUpBeforeClass(): void { require_once WC_ABSPATH . 'includes/admin/class-wc-admin-settings.php'; @@ -49,27 +49,9 @@ public static function setting_groups(): array { ); } - #[Before] - protected function store_rows(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ), 10, 2 ); - } - #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - ( new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' ) )->setValue( null, array() ); - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ) ); - } - - public function record_deprecated_hook( string $hook, string $replacement ): void { - $this->deprecated[] = array( $hook, $replacement ); + protected function forget_the_rejections(): void { + new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' )->setValue( null, array() ); } public function test_the_section_sits_on_the_advanced_tab_under_its_v1_id(): void { @@ -77,7 +59,7 @@ public function test_the_section_sits_on_the_advanced_tab_under_its_v1_id(): voi } public function test_the_section_shows_v1s_two_groups_with_their_defaults(): void { - foreach ( self::KEYS as $key ) { + foreach ( self::OPTIONS as $key ) { delete_option( $key ); } @@ -118,7 +100,7 @@ public function test_a_depth_that_is_not_a_whole_number_of_at_least_one_keeps_th $this->save( array( DWS_LOWC_OPTION_MAX_DEPTH => $posted ) ); self::assertSame( '4', get_option( DWS_LOWC_OPTION_MAX_DEPTH ) ); - self::assertSame( array( 'Maximum linked orders depth was not saved and keeps its previous value. The other changes were saved. Enter a whole number of 1 or more, such as 2.' ), ( new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' ) )->getValue() ); + self::assertSame( array( 'Maximum linked orders depth was not saved and keeps its previous value. The other changes were saved. Enter a whole number of 1 or more, such as 2.' ), new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' )->getValue() ); } public function test_a_whole_depth_of_at_least_one_is_stored(): void { @@ -135,7 +117,6 @@ public function test_a_groups_fields_pass_its_v1_filter_first_then_its_v2_filter $v2_hook = "dws_lowc/settings/{$group}_fields"; $v1_extra = static function ( array $fields ) use ( &$seen ): array { $seen[] = array( 'v1', \array_keys( $fields ) ); - return $fields + array( 'extra' => array( 'type' => 'text', @@ -145,14 +126,13 @@ public function test_a_groups_fields_pass_its_v1_filter_first_then_its_v2_filter }; $v2_spy = static function ( array $fields ) use ( &$seen ): array { $seen[] = array( 'v2', \array_column( $fields, 'id' ) ); - return $fields; }; add_filter( $v1_hook, $v1_extra ); add_filter( $v2_hook, $v2_spy ); try { - $fields = ( new Settings() )->get_section_fields( 'advanced', self::SECTION ); + $fields = new Settings()->get_section_fields( 'advanced', self::SECTION ); } finally { remove_filter( $v1_hook, $v1_extra ); remove_filter( $v2_hook, $v2_spy ); @@ -171,7 +151,7 @@ public function test_the_plugin_row_links_to_the_section_and_to_the_support_foru $links = apply_filters( "plugin_action_links_{$basename}", array( 'deactivate' => 'Deactivate' ), $basename, array(), 'all' ); $meta = apply_filters( 'plugin_row_meta', array(), $basename, array(), 'all' ); - self::assertStringContainsString( \esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=' . self::SECTION ) ), (string) \reset( $links ) ); + self::assertStringContainsString( esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=' . self::SECTION ) ), (string) \reset( $links ) ); self::assertStringContainsString( 'https://wordpress.org/support/plugin/linked-orders-for-woocommerce/', \implode( '', $meta ) ); self::assertSame( array(), apply_filters( 'plugin_row_meta', array(), 'another-plugin/another-plugin.php', array(), 'all' ) ); } @@ -179,7 +159,6 @@ public function test_the_plugin_row_links_to_the_section_and_to_the_support_foru protected function render(): \Dom\HTMLDocument { \ob_start(); \WC_Admin_Settings::output_fields( self::$advanced->get_settings_for_section( self::SECTION ) ); - return \Dom\HTMLDocument::createFromString( '' . \ob_get_clean() . '', \LIBXML_NOERROR ); } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/UninstallTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/UninstallTest.php index fced113..7477760 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/UninstallTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/UninstallTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; use DeepWebSolutions\LinkedOrders\Plugin; +use DeepWebSolutions\LinkedOrders\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\AfterClass; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -10,6 +11,8 @@ #[CoversNothing] final class UninstallTest extends TestCase { + use RunsRequestsTrait; + protected const int USER = 1; #[AfterClass] @@ -23,7 +26,7 @@ public function test_an_uninstall_checks_the_floors_of_its_own_main_file(): void $source = (string) \file_get_contents( DWS_LOWC_FILE ); \file_put_contents( DWS_LOWC_FILE, (string) \preg_replace( '/^(\s*\*\s*Requires PHP:\s*).*$/m', '${1}99.0', $source ) ); $log = (string) \tempnam( \sys_get_temp_dir(), 'dws-tpl-uninstall-' ); - \ini_set( 'error_log', $log ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- The child process's stderr would fail the test. + \ini_set( 'error_log', $log ); try { include \dirname( DWS_LOWC_FILE ) . '/uninstall.php'; @@ -40,20 +43,49 @@ public function test_an_uninstall_in_a_request_that_skips_the_plugin_removes_its update_option( DWS_LOWC_OPTION_DELETE_DATA, 'yes' ); $php = 'uninstall_plugin( "linked-orders-for-woocommerce/linked-orders-for-woocommerce.php" ); echo wp_json_encode( array( defined( "DWS_LOWC_FILE" ), get_option( "' . DWS_LOWC_OPTION_DELETE_DATA . '" ) ) );'; - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . ' --skip-plugins=linked-orders-for-woocommerce eval ' . \escapeshellarg( $php ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- The uninstall needs a request without the plugin. + $request = self::request( $php, '--skip-plugins=' . Plugin::SLUG ); // The other tests share the database, so a request with the plugin stores again what a new site starts with, as the site's next request would. - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . ' eval "" 2>&1', $restored, $restore_status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- The restore needs a request with the plugin. + $restored = self::request( '' ); wp_cache_flush(); wp_roles()->for_site(); - self::assertSame( 0, $status, \implode( "\n", $output ) ); - self::assertSame( '[false,false]', (string) \end( $output ) ); - self::assertSame( 0, $restore_status, \implode( "\n", $restored ) ); + self::assertSame( 0, $request['status'], $request['output'] ); + self::assertSame( '[false,false]', $request['last'], $request['output'] ); + self::assertSame( 0, $restored['status'], $restored['output'] ); self::assertSame( 'no', get_option( DWS_LOWC_OPTION_DELETE_DATA ) ); self::assertTrue( get_role( 'shop_manager' )?->has_cap( DWS_LOWC_CAP_CREATE_LINKED_CHILDREN ) ); } + #[RunInSeparateProcess] + public function test_a_web_uninstall_whose_engine_fails_ends_with_its_message_and_keeps_the_consent(): void { + global $wpdb; + + \define( 'WP_UNINSTALL_PLUGIN', plugin_basename( DWS_LOWC_FILE ) ); + update_option( DWS_LOWC_OPTION_DELETE_DATA, 'yes' ); + $failing = "DELETE FROM `$wpdb->options` WHERE `option_name` = '" . Plugin::PREFIX . "_revision'"; + add_filter( 'query', static fn ( string $query ): string => \str_contains( $query, $failing ) ? 'SELECT * FROM dws_lowc_missing_table' : $query ); + add_filter( 'wp_die_handler', static fn (): \Closure => static fn ( string $message, string $title, array $args ): never => throw new \LogicException( $args['response'] . ' ' . $message ) ); + $wpdb->suppress_errors(); + + try { + include \dirname( DWS_LOWC_FILE ) . '/uninstall.php'; + self::fail( 'The uninstall finished.' ); + } catch ( \LogicException $died ) { + self::assertStringStartsWith( '500 Uninstall could not remove everything: option ' . Plugin::PREFIX . '_revision', $died->getMessage() ); + self::assertSame( 'yes', get_option( DWS_LOWC_OPTION_DELETE_DATA ) ); + } finally { + remove_all_filters( 'query' ); + delete_option( DWS_LOWC_OPTION_DELETE_DATA ); + delete_option( Plugin::PREFIX . '_revision' ); + wp_cache_flush(); + wp_roles()->for_site(); + // The other tests share the database, so the runner stores again what a new site starts with, and the grants act again. + dws_lowc_plugin()->migrations?->run(); + do_action( 'wp_loaded' ); + } + } + #[RunInSeparateProcess] public function test_an_uninstall_after_v1_consent_removes_the_links_on_both_storages_v1s_rows_and_the_capabilities(): void { global $wpdb; diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/bootstrap.php b/plugins/linked-orders-for-woocommerce/tests/Integration/bootstrap.php index cb20ca2..d639dc3 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/bootstrap.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/bootstrap.php @@ -1,5 +1,21 @@ , as `local`, where the framework rethrows boot and migration failures. - `.wp-env.tests.json` is the tests site on , as `production`, which the Integration and end-to-end suites share. A row whose separate process changes the database flushes the parent's object cache, and the end-to-end specs set their own preconditions, because either suite may run first. +- `.wp-env.multisite.json` is a network on , as `production`, with WooCommerce and the plugin network-activated, where the Integration suite's `multisite` group runs and the default run skips it. -Both start WordPress on PHP 8.5 with WooCommerce from `vendor/` and its order tables on. +Each starts WordPress on PHP 8.5 with WooCommerce from `vendor/` and its order tables on. ## Running @@ -33,6 +34,12 @@ Start the tests site, build the scripts and run the Integration suite: composer test:integration ``` +Start the multisite site, build the scripts and run the Integration suite's `multisite` group: + +```sh +composer test:integration:multisite +``` + Run the end-to-end suite, which starts the tests site when it is down and needs the built scripts and, once per machine, Playwright's Chromium: ```sh diff --git a/plugins/internal-comments/tests/Integration/OrderStorage.php b/plugins/linked-orders-for-woocommerce/tests/Support/OrderStorageTrait.php similarity index 82% rename from plugins/internal-comments/tests/Integration/OrderStorage.php rename to plugins/linked-orders-for-woocommerce/tests/Support/OrderStorageTrait.php index 17b04ab..6e2dc53 100644 --- a/plugins/internal-comments/tests/Integration/OrderStorage.php +++ b/plugins/linked-orders-for-woocommerce/tests/Support/OrderStorageTrait.php @@ -1,11 +1,11 @@ storage_filters = array( 'pre_option_woocommerce_custom_orders_table_enabled' => static fn (): string => $hpos ? 'yes' : 'no', 'pre_option_woocommerce_custom_orders_table_data_sync_enabled' => static fn (): string => $sync ? 'yes' : 'no', - 'pre_option_woocommerce_hpos_datastore_caching_enabled' => static fn (): string => $caching ? 'yes' : 'no', + 'pre_option_woocommerce_hpos_datastore_caching_enabled' => static fn (): string => 'no', ); foreach ( $this->storage_filters as $hook => $filter ) { add_filter( $hook, $filter ); @@ -34,7 +34,6 @@ protected function create_order( array $args = array() ): \WC_Order { $order = wc_create_order( $args + array( 'status' => 'pending' ) ); self::assertInstanceOf( \WC_Order::class, $order ); $this->orders[] = $order->get_id(); - return $order; } @@ -47,11 +46,13 @@ protected function delete_orders_and_restore_storage(): void { $order->delete( true ); } } + $this->orders = array(); foreach ( $this->storage_filters as $hook => $filter ) { remove_filter( $hook, $filter ); } + $this->storage_filters = array(); } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Support/RecordsDeprecationsTrait.php b/plugins/linked-orders-for-woocommerce/tests/Support/RecordsDeprecationsTrait.php new file mode 100644 index 0000000..439090f --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/tests/Support/RecordsDeprecationsTrait.php @@ -0,0 +1,48 @@ +listened as $hook ) { + remove_filter( $hook, array( $this, 'pass_through' ) ); + } + + remove_action( 'deprecated_hook_run', array( $this, 'record_deprecation' ) ); + remove_action( 'deprecated_function_run', array( $this, 'record_deprecation' ) ); + remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); + remove_filter( 'deprecated_function_trigger_error', '__return_false' ); + } + + public function record_deprecation( string $name, string $replacement ): void { + $this->deprecated[] = array( $name, $replacement ); + } + + // WordPress reports a deprecated hook only when something listens on it. + protected function listen_on( string ...$hooks ): void { + foreach ( $hooks as $hook ) { + add_filter( $hook, array( $this, 'pass_through' ) ); + $this->listened[] = $hook; + } + } + + public function pass_through( mixed $value = null ): mixed { + return $value; + } +} diff --git a/plugins/linked-orders-for-woocommerce/tests/Support/RestoresOptionsTrait.php b/plugins/linked-orders-for-woocommerce/tests/Support/RestoresOptionsTrait.php new file mode 100644 index 0000000..8d2d3a5 --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/tests/Support/RestoresOptionsTrait.php @@ -0,0 +1,24 @@ +stored_options[ $key ] = get_option( $key ); + } + } + + #[After] + protected function restore_options(): void { + foreach ( $this->stored_options as $key => $value ) { + false === $value ? delete_option( $key ) : update_option( $key, $value ); + } + } +} diff --git a/plugins/linked-orders-for-woocommerce/tests/Support/RunsRequestsTrait.php b/plugins/linked-orders-for-woocommerce/tests/Support/RunsRequestsTrait.php index 25bbd72..09d3ec9 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Support/RunsRequestsTrait.php +++ b/plugins/linked-orders-for-woocommerce/tests/Support/RunsRequestsTrait.php @@ -3,13 +3,18 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Support; trait RunsRequestsTrait { - /** - * @return array{status: int, output: string, last: string} - */ - protected static function request( string $php ): array { - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . ' eval ' . \escapeshellarg( $php ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Each row needs a fresh request that loads the plugin. - $output = \array_values( \array_filter( $output, static fn ( string $line ): bool => '' !== \trim( $line ) ) ); + protected static function request( string $php, string ...$flags ): array { + return self::request_provoking( '', $php, ...$flags ); + } + + // A row whose fixture provokes a diagnostic names it, so that diagnostic and what follows it, such as a fatal error's trace, pass, while any output before it fails the row. + protected static function request_provoking( string $diagnostic, string $php, string ...$flags ): array { + $arguments = \implode( '', \array_map( static fn ( string $flag ): string => ' ' . \escapeshellarg( $flag ), $flags ) ); + \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . "$arguments eval " . \escapeshellarg( $php ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Each row needs a fresh request that loads the plugin. + $output = \array_values( \array_filter( $output, static fn ( string $line ): bool => '' !== \trim( $line ) ) ); + $provoked = '' === $diagnostic ? null : \array_find_key( $output, static fn ( string $line ): bool => \str_contains( $line, $diagnostic ) ); + self::assertSame( array(), \array_slice( $output, 0, \is_int( $provoked ) ? $provoked : \max( 0, \count( $output ) - 1 ) ), \implode( "\n", $output ) ); return array( 'status' => $status, 'output' => \implode( "\n", $output ), diff --git a/plugins/linked-orders-for-woocommerce/tests/Unit/DirectoryStubsTest.php b/plugins/linked-orders-for-woocommerce/tests/Unit/DirectoryStubsTest.php new file mode 100644 index 0000000..17c9feb --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/tests/Unit/DirectoryStubsTest.php @@ -0,0 +1,33 @@ + $file->isDir() && ! \str_starts_with( $file->getFilename(), '.' ) && ! \in_array( $file->getFilename(), self::SKIPPED, true ) + ), + \RecursiveIteratorIterator::SELF_FIRST + ); + $missing = array(); + foreach ( array( $root, ...\array_keys( \iterator_to_array( $directories ) ) ) as $directory ) { + $stub = "$directory/index.php"; + if ( ! \is_file( $stub ) || " 'wp-plugin/woocommerce' === $package['name'] ); + $entry = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/' . Plugin::SLUG . '.php' ); + + self::assertIsArray( $woocommerce ); + self::assertSame( 1, \preg_match( '/^\s*\*\s*WC tested up to:\s*(\S+)\s*$/m', $entry, $header ) ); + self::assertSame( \implode( '.', \array_slice( \explode( '.', $woocommerce['version'] ), 0, 2 ) ), $header[1] ); + } +} diff --git a/plugins/linked-orders-for-woocommerce/tests/Unit/FootprintTest.php b/plugins/linked-orders-for-woocommerce/tests/Unit/FootprintTest.php index 79a7bc7..8971684 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Unit/FootprintTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Unit/FootprintTest.php @@ -17,7 +17,7 @@ public function test_the_prefix_has_one_segment_after_dws_so_no_prefix_in_its_fa } public function test_every_kind_is_one_the_uninstall_engine_removes(): void { - $doc = (string) ( new \ReflectionClass( Uninstaller::class ) )->getDocComment(); + $doc = (string) new \ReflectionClass( Uninstaller::class )->getDocComment(); self::assertSame( 1, \preg_match( '/@phpstan-type Footprint (.+)$/m', $doc, $type ) ); \preg_match_all( '/(\w+)\?:/', $type[1], $kinds ); diff --git a/plugins/linked-orders-for-woocommerce/tests/Unit/ReadmeTest.php b/plugins/linked-orders-for-woocommerce/tests/Unit/ReadmeTest.php index 499ad42..f014d5c 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Unit/ReadmeTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Unit/ReadmeTest.php @@ -16,6 +16,12 @@ public function test_the_readme_repeats_the_entry_files_version_and_floors(): vo self::assertSame( $entry['Requires PHP'], $readme['Requires PHP'] ); } + // Plugin Check refuses a Tested up to in the entry, in any case, as WordPress reads headers, so the readme holds the only one. + public function test_the_entry_leaves_tested_up_to_to_the_readme(): void { + self::assertDoesNotMatchRegularExpression( '/^(?:[ \t]*<\?(?:php)?)?[ \t\/*#@]*Tested up to:/mi', (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/linked-orders-for-woocommerce.php' ) ); + self::assertArrayHasKey( 'Tested up to', self::read_fields( 'readme.txt' ) ); + } + public function test_the_readme_holds_the_changelog_markers_the_sync_fills(): void { $readme = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/readme.txt' ); @@ -24,7 +30,6 @@ public function test_the_readme_holds_the_changelog_markers_the_sync_fills(): vo protected static function read_fields( string $file ): array { \preg_match_all( '/^[\s*]*([A-Za-z ]+):\s*(\S.*?)\s*$/m', (string) \file_get_contents( \dirname( __DIR__, 2 ) . "/$file" ), $matches ); - return \array_combine( $matches[1], $matches[2] ); } } diff --git a/plugins/linked-orders-for-woocommerce/uninstall.php b/plugins/linked-orders-for-woocommerce/uninstall.php index bc52a47..e789915 100644 --- a/plugins/linked-orders-for-woocommerce/uninstall.php +++ b/plugins/linked-orders-for-woocommerce/uninstall.php @@ -31,7 +31,6 @@ require_once __DIR__ . '/includes/footprint.php'; require_once __DIR__ . '/includes/options.php'; $dws_lowc_footprint = dws_lowc_get_footprint(); - $dws_lowc_consent = DWS_LOWC_OPTION_DELETE_DATA; $dws_lowc_meta_types = MetaTypes::DEFINITIONS; if ( ! class_exists( Uninstaller::class ) ) { throw new RuntimeException( 'its uninstall engine is missing.' ); @@ -46,11 +45,20 @@ return; } -Uninstaller::run( - $dws_lowc_file, - $dws_lowc_footprint, - array( - 'consent' => array( 'option' => $dws_lowc_consent ), - 'meta_types' => $dws_lowc_meta_types, - ) -); +try { + Uninstaller::run( + $dws_lowc_file, + $dws_lowc_footprint, + array( + 'consent' => array( 'option' => DWS_LOWC_OPTION_DELETE_DATA ), + 'meta_types' => $dws_lowc_meta_types, + ) + ); +} catch ( RuntimeException $exception ) { + if ( defined( 'WP_CLI' ) && true === WP_CLI ) { + throw $exception; + } + + // wp_die() ends the request before WordPress deletes the files, so the next uninstall retries, and shows the reason where a throw shows only the critical-error page. + wp_die( esc_html( $exception->getMessage() ), '', array( 'response' => 500 ) ); +} diff --git a/plugins/locked-payment-methods-for-woocommerce/.distignore b/plugins/locked-payment-methods-for-woocommerce/.distignore index 5faf091..8aa76ac 100644 --- a/plugins/locked-payment-methods-for-woocommerce/.distignore +++ b/plugins/locked-payment-methods-for-woocommerce/.distignore @@ -1,40 +1,52 @@ -.cache -.distignore -.editorconfig +# Baseline wp dist-archive exclusions for WordPress projects. + .git .github -.gitignore .idea .vscode -.wp-env.json -.wp-env.override.json -.wp-env.tests.json - +.editorconfig +.gitattributes +.gitignore +.distignore AGENTS.md -CHANGELOG.md CLAUDE.md CONTRIBUTING.md -README.md SECURITY.md - -changelog/ -docs/ - -node_modules/ -tests/ -vendor/bin/ - +/changelog +node_modules +tests +vendor/bin composer.json composer.lock package.json package-lock.json +.phpcs*.xml* +phpcs*.xml* +.phpstan*.neon* +phpstan*.neon* +phpunit*.xml* +.phpunit.result.cache +.phpunit.cache +infection.json* +eslint.config.* +stylelint.config.* tsconfig.json -eslint.config.mjs -stylelint.config.js -phpcs.dist.xml -phpcs.tests.dist.xml -phpstan.dist.neon -phpstan.safeguards.neon -phpunit.dist.xml -playwright.config.js +playwright.config.* +postcss.config.* scoper.inc.php +.wp-env* +.wordpress-org + +# Secret material, with .pem anchored to the root so certificates bundled under vendor/ still ship. +.env* +/*.pem +*.key +id_rsa* +.npmrc +auth.json + +# The plugin's own exclusions, beyond configs' baseline above. +.cache +CHANGELOG.md +README.md +docs/ diff --git a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/audit.yml b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/audit.yml index ff6c180..345f398 100644 --- a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/audit.yml +++ b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/audit.yml @@ -5,17 +5,18 @@ on: branches: [trunk] pull_request: schedule: - # Weekly Monday at 07:00 UTC — staggered after dependabot (06:00) so PRs from - # dependency bumps surface audit results in the same morning batch. + # Weekly, so advisories published against an unchanged lock surface without a push. - cron: '0 7 * * 1' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read jobs: audit: name: Audit - # npm gate audits production deps only — the dev-tooling graph (wp-scripts/playwright trees) is advisory via dependabot alerts. + # Only production npm packages gate the audit, since a release ships none of the development tooling. uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@b107001cce9f2960495d9d50481c8540d228f60c - with: - npm-audit-flags: '--omit=dev --audit-level=high' diff --git a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/codeql.yml b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/codeql.yml index 1e142c0..cd74e65 100644 --- a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/codeql.yml +++ b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/codeql.yml @@ -16,8 +16,11 @@ permissions: {} jobs: analyze: + name: CodeQL permissions: actions: read contents: read security-events: write uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@b107001cce9f2960495d9d50481c8540d228f60c + with: + languages: '["actions", "javascript-typescript"]' diff --git a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/quality.yml b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/quality.yml index d362366..6263e15 100644 --- a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/quality.yml +++ b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/quality.yml @@ -5,19 +5,23 @@ on: branches: [trunk] pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read jobs: lint-php: - name: PHP Lint + name: Lint PHP uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@b107001cce9f2960495d9d50481c8540d228f60c with: php-version: '8.5' - scripts: '["lint:php:phpcs", "lint:php:phpcs:tests", "lint:php:phpstan"]' + scripts: '["lint:php:phpcs", "lint:php:phpcs:tests", "lint:php:phpstan", "changelog:validate"]' lint-scripts-styles: - name: Scripts and styles lint + name: Lint scripts and styles uses: ahegyes/wordpress-configs/.github/workflows/reusable-scripts-styles-lint.yml@b107001cce9f2960495d9d50481c8540d228f60c with: scripts: '["lint:scripts", "lint:styles", "lint:types"]' @@ -41,7 +45,7 @@ jobs: id: slug run: | slug=$(jq -r '.extra["text-domain"] // empty' composer.json) - if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + if ! [[ "$slug" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]]; then echo "::error::composer.json's extra.text-domain must be the plugin's kebab-case slug — got '$slug'." exit 1 fi @@ -58,6 +62,7 @@ jobs: readme-lint: name: readme.txt lint runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -66,18 +71,3 @@ jobs: with: path: readme.txt fail-on: error - - changelog: - name: Changelog fragments - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 - with: - php-version: '8.5' - coverage: none - tools: composer:v2 - - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 - - run: composer changelog:validate diff --git a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/release.yml b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/release.yml index eba32e9..d6b730a 100644 --- a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/release.yml +++ b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: id: slug run: | slug=$(jq -r '.extra["text-domain"] // empty' composer.json) - if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + if ! [[ "$slug" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]]; then echo "::error::composer.json's extra.text-domain must be the plugin's kebab-case slug — got '$slug'." exit 1 fi diff --git a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/tests-nightly.yml b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/tests-nightly.yml index eec84c9..69824e8 100644 --- a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/tests-nightly.yml +++ b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/tests-nightly.yml @@ -19,6 +19,15 @@ jobs: composer-script: 'test:integration:run' wp-env-config-file: '.wp-env.tests.json' + integration-multisite: + name: Integration (multisite) + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c + with: + php-version: '8.5' + wp-env-core: 'https://wordpress.org/nightly-builds/wordpress-latest.zip' + composer-script: 'test:integration:multisite:run' + wp-env-config-file: '.wp-env.multisite.json' + e2e: name: E2E uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@b107001cce9f2960495d9d50481c8540d228f60c diff --git a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/tests.yml b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/tests.yml index fb6ef15..466d0d7 100644 --- a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/tests.yml +++ b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/tests.yml @@ -6,6 +6,10 @@ on: pull_request: workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -22,8 +26,6 @@ jobs: name: WordPress versions runs-on: ubuntu-latest timeout-minutes: 5 - permissions: - contents: read outputs: versions: ${{ steps.versions.outputs.versions }} steps: @@ -41,7 +43,7 @@ jobs: echo "::error::$entry declares no Requires at least header." exit 1 fi - offers=$(curl -fsS https://api.wordpress.org/core/version-check/1.7/) + offers=$(curl -fsS --retry 3 --retry-all-errors --max-time 20 https://api.wordpress.org/core/version-check/1.7/) versions=$(printf '%s' "$offers" | jq -c --arg floor "$floor" '[.offers[].version | select((split(".")[0:2] | map(tonumber)) >= ($floor | split(".")[0:2] | map(tonumber)))] | unique | if length == 0 then error("No WordPress release at or above \($floor).") else . end') echo "versions=$versions" >> "$GITHUB_OUTPUT" @@ -59,6 +61,20 @@ jobs: composer-script: 'test:integration:run' wp-env-config-file: '.wp-env.tests.json' + integration-multisite: + name: Integration (multisite) + needs: wordpress + strategy: + fail-fast: false + matrix: + wp: ${{ fromJSON(needs.wordpress.outputs.versions) }} + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c + with: + php-version: '8.5' + wp-version: ${{ matrix.wp }} + composer-script: 'test:integration:multisite:run' + wp-env-config-file: '.wp-env.multisite.json' + e2e: name: E2E needs: wordpress diff --git a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/workflow-checks.yml b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/workflow-checks.yml index bafdc75..e954a68 100644 --- a/plugins/locked-payment-methods-for-woocommerce/.github/workflows/workflow-checks.yml +++ b/plugins/locked-payment-methods-for-woocommerce/.github/workflows/workflow-checks.yml @@ -3,12 +3,11 @@ name: Workflow Checks on: push: branches: [trunk] - paths: ['.github/workflows/**'] + paths: ['.github/**'] pull_request: - paths: ['.github/workflows/**'] + paths: ['.github/**'] schedule: - # Weekly Monday 06:00 UTC. Keeps zizmor's online audits (e.g. - # known-vulnerable-actions) fresh between workflow changes. + # Re-runs zizmor's online audits between workflow changes. - cron: '0 6 * * 1' workflow_dispatch: @@ -20,6 +19,7 @@ permissions: {} jobs: checks: + name: Workflow Checks permissions: contents: read security-events: write diff --git a/plugins/locked-payment-methods-for-woocommerce/.gitignore b/plugins/locked-payment-methods-for-woocommerce/.gitignore index 4f496cf..543ae90 100644 --- a/plugins/locked-payment-methods-for-woocommerce/.gitignore +++ b/plugins/locked-payment-methods-for-woocommerce/.gitignore @@ -1,6 +1,14 @@ vendor-prefixed tests/.cache -.wp-env.override.json +.wp-env*.override.json + +# Secret material is never tracked, so these match at any depth as a backstop against an accidental git add. +.env* +*.pem +*.key +id_rsa* +.npmrc +auth.json # Created by https://www.toptal.com/developers/gitignore/api/node,linux,macos,phpunit,windows,composer,phpstorm+all,visualstudiocode # Edit at https://www.toptal.com/developers/gitignore?templates=node,linux,macos,phpunit,windows,composer,phpstorm+all,visualstudiocode diff --git a/plugins/locked-payment-methods-for-woocommerce/.wp-env.multisite.json b/plugins/locked-payment-methods-for-woocommerce/.wp-env.multisite.json new file mode 100644 index 0000000..fa5d4a0 --- /dev/null +++ b/plugins/locked-payment-methods-for-woocommerce/.wp-env.multisite.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://schemas.wp.org/trunk/wp-env.json", + "core": "WordPress/WordPress#tags/7.1.4", + "phpVersion": "8.5", + "port": 8830, + "testsEnvironment": false, + "multisite": true, + "mappings": { + "wp-content/plugins/locked-payment-methods-for-woocommerce": ".", + "wp-content/plugins/woocommerce": "./vendor/wp-plugin/woocommerce" + }, + "config": { + "WP_DEBUG": true, + "SCRIPT_DEBUG": true, + "WP_ENVIRONMENT_TYPE": false + }, + "lifecycleScripts": { + "afterStart": "node_modules/.bin/wp-env --config .wp-env.multisite.json run cli wp plugin activate woocommerce --network && node_modules/.bin/wp-env --config .wp-env.multisite.json run cli wp wc hpos enable --ignore-plugin-compatibility && node_modules/.bin/wp-env --config .wp-env.multisite.json run cli wp plugin activate locked-payment-methods-for-woocommerce --network" + } +} diff --git a/plugins/locked-payment-methods-for-woocommerce/AGENTS.md b/plugins/locked-payment-methods-for-woocommerce/AGENTS.md index 69a186d..c52f18e 100644 --- a/plugins/locked-payment-methods-for-woocommerce/AGENTS.md +++ b/plugins/locked-payment-methods-for-woocommerce/AGENTS.md @@ -38,6 +38,7 @@ changelog/ the pending entries changelog:write turns into CHAN composer packages-install # dependencies, then php-scoper into vendor-prefixed/ composer test:unit composer test:integration # starts .wp-env.tests.json, then runs test:integration:run +composer test:integration:multisite # starts .wp-env.multisite.json, then runs the Integration suite's multisite group, which the default run skips composer quality-check # manifest and changelog validation, PHPCS (both profiles), PHPStan (both profiles), Unit and Integration composer changelog:add # stages an entry in changelog/, which the release step in the monorepo's CONTRIBUTING.md writes npm run setup # both installs, then the development site @@ -51,23 +52,28 @@ Run wp-scripts only through the npm scripts or `node_modules/.bin/wp-scripts`: ` - `.wp-env.json`: development, as `local`, on port 8826, behind `npm run setup` and `wp-env:*`. The framework rethrows boot and migration failures there. - `.wp-env.tests.json`: Integration and end to end, as `production`, on port 8827, behind `wp-env:tests:*`, `composer test:integration` and `npm run test:e2e`. The end-to-end suite deactivates and reactivates WooCommerce there. +- `.wp-env.multisite.json`: a network with WooCommerce and the plugin network-activated, as `production`, on port 8830, behind `wp-env:multisite:*` and `composer test:integration:multisite`, for the Integration suite's `multisite` group. -Both run PHP 8.5 with WooCommerce and Groups from `vendor/` and WooCommerce's order tables on; CI runs Integration and end to end on every WordPress branch from the entry's `Requires at least` up, and nightly on WordPress trunk. The monorepo's shared environment runs the Integration suite beside Linked Orders and Internal Comments, without the `groups` and `wc-memberships` groups. +Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on, and the development and tests sites with Groups too; CI runs Integration and end to end on every WordPress branch from the entry's `Requires at least` up, and nightly on WordPress trunk. The monorepo's shared environment runs the Integration suite beside Linked Orders and Internal Comments, without the `groups` and `multisite` groups, and its multisite environment runs the `multisite` group. ## Conventions -1. **Placement.** `src/` holds the composition root, its components and the plan tab host. Code that registers no hooks, such as keys, readers, v1's helper API, the footprint and migration steps, is `dws_lpmwc_` functions and `DWS_LPMWC_` constants in `includes/`, which parses on PHP 7.4: no `mixed` or union types, typed constants, `match`, nullsafe calls or first-class callables there. An include that reads `Plugin` while it loads must sort after `metadata.php`, so `capabilities.php` and `grants.php` name their keys as literals. Container definitions are composition and stay in `Plugin::get_definitions()`. -2. **Strategies.** Each way of unlocking is a component in `src/UnlockStrategies/`, gated on its switch, and an integration on its plugin too through a static `is_available()` that the settings read. It answers `dws_lpmwc/payment_method/should_lock` from the arguments alone, through an `@internal` static `unlocks()` that v1's per-strategy checks call, and returns `false` or the lock it was given. The safeguard passes the current user and, on an order's own payment page with its key, that order. +1. **Placement.** `src/` holds the composition root, its components and the plan tab host. Code that registers no hooks, such as keys, readers, v1's helper API, the footprint and migration steps, is `dws_lpmwc_` functions and `DWS_LPMWC_` constants in `includes/`, which parses on PHP 7.4: no `mixed` or union types, typed constants, `match`, nullsafe calls or first-class callables there. Container definitions are composition and stay in `Plugin::get_definitions()`. +2. **Strategies.** Each way of unlocking is a component in `src/UnlockStrategies/`, gated on its switch, and an integration on its plugin too through a static `is_available()` that the settings read. It answers `dws_lpmwc/payment_method/should_lock` from the arguments alone, through a static `unlocks()` that v1's per-strategy checks call, and returns `false` or the lock it was given. The safeguard passes the current user and, on an order's own payment page with its key, that order. 3. **Request context.** `is_admin()` is false on REST requests and true in admin-ajax ones, so an admin-only feature decides the request context in its callbacks, never in `should_load()`. 4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. 5. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input. 6. **Options and data.** The settings are read only through `includes/options.php`'s typed readers, safeguards excepted, and each subject's grants only through `includes/grants.php`'s readers. Everything stored lives under `dws_lpmwc_`, or `_dws_lpmwc_` for hidden meta; a user's grants are a user option under the site's table prefix, read from that key alone. The plugin's settings fire no settings hooks of their own, since WordPress's `option_{key}` reaches every read; v1's value filters fire where v1 read through them. 7. **Grants forms** offer the locked payment methods and the subject's other grants, through `dws_lpmwc_get_grant_choices()`, since a `checkboxes` save keeps only the choices it offers. The profile, order and plan forms are the framework's `ObjectForm`s; the Groups field rides Groups' own forms, whose nonce and capability Groups checks before its save actions, and checks the plugin's capability in each callback. -8. **Order storage.** Order code goes through WC CRUD and WooCommerce's screen ids, never the posts table, and every Integration row whose outcome the order storage can change runs on all four storages through `OrderStorage`. +8. **Order storage.** Order code goes through WC CRUD and WooCommerce's screen ids, never the posts table, and every Integration row whose outcome the order storage can change runs on all four storages through `OrderStorageTrait`. 9. **v1 hooks.** A hook v1 fired keeps its v1 literal, fired through `apply_filters_deprecated()` or `do_action_deprecated()` with `'2.0.0'` and its replacement, before the v2 name; a filter on the locked list only adds ids. -10. **Safeguards** live in `includes/safeguards.php`, call only WordPress, WooCommerce and that file, and relax only through their own filter, through `dws_lpmwc_is_relaxed()` (the template's recipes). The entry loads them before the floor check, so they parse on PHP 7.4 and call only WordPress APIs present at the oldest WordPress the bootstrap package runs on, or guard them, which `phpstan.safeguards.neon`, the file's only PHPStan profile, checks with WooCommerce's stubs. +10. **Safeguards** live in `includes/safeguards.php`, call WordPress, that file and WooCommerce, and relax only through their own filter, through `dws_lpmwc_is_relaxed()` (the template's recipes). The entry loads them before the floor check, so they parse on PHP 7.4 and call only what their hook hands them, WordPress APIs present at the oldest WordPress the bootstrap package runs on and WooCommerce APIs present since 3.6, guarding anything newer with `function_exists()` or `method_exists()`. WordPress loads the plugin while WooCommerce is inactive, so a safeguard that calls WooCommerce is hooked from `dws_lpmwc_add_woocommerce_safeguards()`, which the file runs on `woocommerce_loaded`, or at once where that has fired already, as on the request that activates the plugin; WooCommerce fires it, since 3.6, once every plugin has loaded. `phpstan.safeguards.neon`, the file's only PHPStan profile, reads the whole file, with WooCommerce's stubs loaded, and checks the WordPress floor; review checks the WooCommerce floor, which no tool reads. 11. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. +## Contract + +The plugin's contract is the elements tagged `@api`, the hooks it fires, and its option, meta and capability names and REST routes; breaking it ships as the next major. Every other function, constant and class, `src/` included, is internal and changes in any release. + ## Style - Every PHP file opens with `boot(); + $plugin = dws_lpmwc_plugin(); } catch ( Throwable $error ) { dws_lpmwc_handle_build_error( $error ); + return; } + + $plugin->boot(); // PluginBoot reports a failed composition itself, and a hook listener's throw is not a build error. } /** @@ -75,14 +80,19 @@ function dws_lpmwc_boot(): void { */ function dws_lpmwc_deactivate(): void { try { - dws_lpmwc_plugin()->deactivate(); + $plugin = dws_lpmwc_plugin(); } catch ( Throwable $error ) { dws_lpmwc_handle_build_error( $error ); + return; } + + $plugin->deactivate(); } /** - * Declares that the plugin works with WooCommerce's order tables, which its tests cover on both order storages. + * Declares that the plugin works with WooCommerce's order tables. + * + * Each WooCommerce feature flag is declared from evidence, here the tests that cover both order storages. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/locked-payment-methods-for-woocommerce/includes/deprecated.php b/plugins/locked-payment-methods-for-woocommerce/includes/deprecated.php index e0604d2..01ba6ac 100644 --- a/plugins/locked-payment-methods-for-woocommerce/includes/deprecated.php +++ b/plugins/locked-payment-methods-for-woocommerce/includes/deprecated.php @@ -37,6 +37,8 @@ /** * The plugin's untranslated name. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lpmwc_get_plugin_name(). @@ -46,6 +48,8 @@ /** * The plugin's version. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lpmwc_get_plugin_version(). @@ -55,6 +59,8 @@ /** * The path of the plugin's main file. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use DWS_LPMWC_FILE. @@ -64,6 +70,8 @@ /** * The oldest PHP version the plugin runs on. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -73,6 +81,8 @@ /** * The oldest WordPress version the plugin runs on. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -107,6 +117,8 @@ /** * Returns the plugin's name, translated once init has fired. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lpmwc_get_plugin_name(). @@ -115,13 +127,14 @@ */ function dws_lpmwc_name(): string { _deprecated_function( __FUNCTION__, '2.0.0', 'dws_lpmwc_get_plugin_name()' ); - return dws_lpmwc_get_plugin_name(); } /** * Returns the plugin's version. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lpmwc_get_plugin_version(). @@ -130,13 +143,14 @@ function dws_lpmwc_name(): string { */ function dws_lpmwc_version(): string { _deprecated_function( __FUNCTION__, '2.0.0', 'dws_lpmwc_get_plugin_version()' ); - return dws_lpmwc_get_plugin_version(); } /** * Returns the path of the plugin's main file. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -145,13 +159,14 @@ function dws_lpmwc_version(): string { */ function dws_lpmwc_path(): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return DWS_LPMWC_PATH; } /** * Returns the oldest PHP version the plugin runs on. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -160,13 +175,14 @@ function dws_lpmwc_path(): string { */ function dws_lpmwc_min_php(): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return DWS_LPMWC_MIN_PHP; } /** * Returns the oldest WordPress version the plugin runs on. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -175,13 +191,14 @@ function dws_lpmwc_min_php(): string { */ function dws_lpmwc_min_wp(): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return DWS_LPMWC_MIN_WP; } /** * Returns a hook name as v1 built it for the plugin. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -193,13 +210,14 @@ function dws_lpmwc_min_wp(): string { */ function dws_lpmwc_get_hook_tag( string $name, $extra = array() ): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return dws_lpmwc_join_v1_hook_tag( 'locked_payment_methods_for_woocommerce', $name, $extra ); } /** * Returns a hook name as v1 built it for one of its components, or null for an id v1 had no component for. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -212,7 +230,6 @@ function dws_lpmwc_get_hook_tag( string $name, $extra = array() ): string { */ function dws_lpmwc_get_component_hook_tag( string $component_id, string $name, $extra = array() ): ?string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return isset( DWS_LPMWC_V1_COMPONENTS[ $component_id ] ) ? dws_lpmwc_join_v1_hook_tag( 'locked_payment_methods_for_woocommerce/' . DWS_LPMWC_V1_COMPONENTS[ $component_id ], $name, $extra ) : null; } @@ -277,6 +294,8 @@ function dws_lpmwc_fire_v1_initialization_failure( Throwable $error ): void { /** * Returns whether a payment method stays locked for a user, as the plugin's strategies judge it, firing v1's filter first. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lpmwc_is_payment_method_locked(). @@ -301,13 +320,14 @@ function dws_lpmwc_check_payment_method_access_for_user( string $locked_method_i * @param int $user_id The user's id. */ $lock = apply_filters_deprecated( 'locked_payment_methods_for_woocommerce/check_payment_method_access_for_user', array( true, $locked_method_id, $user_id ), '2.0.0', 'dws_lpmwc/payment_method/should_lock' ); - return dws_lpmwc_should_lock( false !== $lock, $locked_method_id, $user_id, 0 ); } /** * Returns whether a payment method stays locked for paying an order, as the plugin's strategies judge it for the order and its customer, firing v1's filter first. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lpmwc_is_payment_method_locked(). @@ -333,13 +353,14 @@ function dws_lpmwc_check_payment_method_access_for_wc_order( string $locked_meth * @param int $order_id The order's id. */ $lock = apply_filters_deprecated( 'locked_payment_methods_for_woocommerce/check_payment_method_access_for_wc_order', array( true, $locked_method_id, $order_id ), '2.0.0', 'dws_lpmwc/payment_method/should_lock' ); - return dws_lpmwc_should_lock( false !== $lock, $locked_method_id, $order instanceof WC_Order ? $order->get_customer_id() : 0, $order_id ); } /** * Returns whether the user-profile strategy leaves a payment method locked for a user, true while the strategy is off. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lpmwc_is_payment_method_locked(). @@ -351,13 +372,14 @@ function dws_lpmwc_check_payment_method_access_for_wc_order( string $locked_meth */ function dws_lpmwc_check_payment_method_access_via_user_meta( string $locked_method_id, int $user_id ): bool { _deprecated_function( __FUNCTION__, '2.0.0', 'dws_lpmwc_is_payment_method_locked()' ); - return ! ( UserProfileStrategy::should_load() && UserProfileStrategy::unlocks( $locked_method_id, $user_id ) ); } /** * Returns whether the role strategy leaves a payment method locked for a user, true while the strategy is off. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lpmwc_is_payment_method_locked(). @@ -369,13 +391,14 @@ function dws_lpmwc_check_payment_method_access_via_user_meta( string $locked_met */ function dws_lpmwc_check_payment_method_access_via_user_roles( string $locked_method_id, int $user_id ): bool { _deprecated_function( __FUNCTION__, '2.0.0', 'dws_lpmwc_is_payment_method_locked()' ); - return ! ( UserRoleStrategy::should_load() && UserRoleStrategy::unlocks( $locked_method_id, $user_id ) ); } /** * Returns whether the Groups strategy leaves a payment method locked for a user, true while the strategy is off. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lpmwc_is_payment_method_locked(). @@ -387,13 +410,14 @@ function dws_lpmwc_check_payment_method_access_via_user_roles( string $locked_me */ function dws_lpmwc_check_payment_method_access_via_groups( string $locked_method_id, int $user_id ): bool { _deprecated_function( __FUNCTION__, '2.0.0', 'dws_lpmwc_is_payment_method_locked()' ); - return ! ( GroupsStrategy::should_load() && GroupsStrategy::unlocks( $locked_method_id, $user_id ) ); } /** * Returns whether the WooCommerce Memberships strategy leaves a payment method locked for a user, true while the strategy is off. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lpmwc_is_payment_method_locked(). @@ -405,13 +429,14 @@ function dws_lpmwc_check_payment_method_access_via_groups( string $locked_method */ function dws_lpmwc_check_payment_method_access_via_wc_memberships( string $locked_method_id, int $user_id ): bool { _deprecated_function( __FUNCTION__, '2.0.0', 'dws_lpmwc_is_payment_method_locked()' ); - return ! ( WooCommerceMembershipsStrategy::should_load() && WooCommerceMembershipsStrategy::unlocks( $locked_method_id, $user_id ) ); } /** * Returns whether the order strategy leaves a payment method locked for paying an order, true while the strategy is off. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use dws_lpmwc_is_payment_method_locked(). @@ -423,13 +448,14 @@ function dws_lpmwc_check_payment_method_access_via_wc_memberships( string $locke */ function dws_lpmwc_check_payment_method_access_via_wc_order_meta( string $locked_method_id, int $order_id ): bool { _deprecated_function( __FUNCTION__, '2.0.0', 'dws_lpmwc_is_payment_method_locked()' ); - return ! ( OrderStrategy::should_load() && OrderStrategy::unlocks( $locked_method_id, $order_id ) ); } /** * Returns a setting's stored value, by v1's field id and group, or null for an id v1 had no setting for. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use get_option(). @@ -442,13 +468,14 @@ function dws_lpmwc_check_payment_method_access_via_wc_order_meta( string $locked function dws_lpmwc_get_raw_setting( string $field_id, ?string $group = null ) { _deprecated_function( __FUNCTION__, '2.0.0', 'get_option()' ); $setting = dws_lpmwc_find_v1_setting( $field_id, $group ); - - return null === $setting ? null : get_option( $setting['key'], null ); + return is_null( $setting ) ? null : get_option( $setting['key'], null ); } /** * Returns a setting's value, of the setting's kind, by v1's field id and group, through v1's value filters, or null for an id v1 had no setting for. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use get_option(). @@ -460,13 +487,14 @@ function dws_lpmwc_get_raw_setting( string $field_id, ?string $group = null ) { */ function dws_lpmwc_get_validated_setting( string $field_id, ?string $group = null ) { _deprecated_function( __FUNCTION__, '2.0.0', 'get_option()' ); - return dws_lpmwc_read_v1_setting( $field_id, $group ); } /** * Returns a value as the setting's kind, by v1's field id and group: a boolean for a switch and a list of strings for a list, falling back to the setting's default. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 @@ -480,13 +508,14 @@ function dws_lpmwc_get_validated_setting( string $field_id, ?string $group = nul function dws_lpmwc_validate_setting( $value, string $field_id, ?string $group = null ) { _deprecated_function( __FUNCTION__, '2.0.0' ); $setting = dws_lpmwc_find_v1_setting( $field_id, $group ); - - return null === $setting ? null : dws_lpmwc_validate_v1_setting( $value, $setting['key'] ); + return is_null( $setting ) ? null : dws_lpmwc_validate_v1_setting( $value, $setting['key'] ); } /** * Returns a General setting's value, of the setting's kind, by v1's field id, through v1's value filter. * + * @api + * * @since 1.0.0 * @version 2.0.0 * @deprecated 2.0.0 Use get_option(). @@ -497,7 +526,6 @@ function dws_lpmwc_validate_setting( $value, string $field_id, ?string $group = */ function dws_lpmwc_get_validated_general_setting( string $field_id ) { _deprecated_function( __FUNCTION__, '2.0.0', 'get_option()' ); - return dws_lpmwc_read_v1_setting( $field_id, 'general' ); } @@ -513,7 +541,7 @@ function dws_lpmwc_get_validated_general_setting( string $field_id ) { * @return array{group: string, field: string, key: string}|null */ function dws_lpmwc_find_v1_setting( string $field_id, ?string $group ): ?array { - if ( null === $group ) { + if ( is_null( $group ) ) { $parts = explode( '/', $field_id, 2 ); if ( 2 !== count( $parts ) ) { return null; @@ -522,8 +550,7 @@ function dws_lpmwc_find_v1_setting( string $field_id, ?string $group ): ?array { } $key = DWS_LPMWC_V1_SETTINGS[ $group ][ $field_id ] ?? null; - - return null === $key ? null : array( + return is_null( $key ) ? null : array( 'group' => $group, 'field' => $field_id, 'key' => $key, @@ -543,7 +570,7 @@ function dws_lpmwc_find_v1_setting( string $field_id, ?string $group ): ?array { */ function dws_lpmwc_read_v1_setting( string $field_id, ?string $group ) { $setting = dws_lpmwc_find_v1_setting( $field_id, $group ); - if ( null === $group ) { + if ( is_null( $group ) ) { /** * Filters a setting's value, by its group-prefixed v1 id, before its group reads it. * @@ -554,13 +581,13 @@ function dws_lpmwc_read_v1_setting( string $field_id, ?string $group ) { * @param mixed $value Null, or the value a callback answers. * @param string $field_id v1's id of the setting, prefixed with its group and a slash. */ - $value = apply_filters_deprecated( 'locked_payment_methods_for_woocommerce/settings/get_validated_option_value', array( null, $field_id ), '2.0.0', null === $setting ? 'get_option' : 'option_' . $setting['key'] ); - if ( null !== $value ) { + $value = apply_filters_deprecated( 'locked_payment_methods_for_woocommerce/settings/get_validated_option_value', array( null, $field_id ), '2.0.0', is_null( $setting ) ? 'get_option' : 'option_' . $setting['key'] ); + if ( ! is_null( $value ) ) { return $value; } } - return null === $setting ? null : dws_lpmwc_apply_v1_setting_filter( dws_lpmwc_validate_v1_setting( get_option( $setting['key'], null ), $setting['key'] ), $setting['group'], $setting['field'], 'option_' . $setting['key'] ); + return is_null( $setting ) ? null : dws_lpmwc_apply_v1_setting_filter( dws_lpmwc_validate_v1_setting( get_option( $setting['key'], null ), $setting['key'] ), $setting['group'], $setting['field'], 'option_' . $setting['key'] ); } /** diff --git a/plugins/locked-payment-methods-for-woocommerce/includes/footprint.php b/plugins/locked-payment-methods-for-woocommerce/includes/footprint.php index 64b4d47..367de82 100644 --- a/plugins/locked-payment-methods-for-woocommerce/includes/footprint.php +++ b/plugins/locked-payment-methods-for-woocommerce/includes/footprint.php @@ -13,7 +13,9 @@ defined( 'ABSPATH' ) || exit; /** - * Returns the plugin's stored data by kind, which covers every option, transient, user meta key and scheduled job under the plugin's prefix, the grants on users, orders, Groups groups and WooCommerce Memberships plans, and what v1 stored under its own names: its settings, version rows, notices, grants and capabilities. + * Returns the plugin's stored data by kind. + * + * It also covers the grants on Groups groups and WooCommerce Memberships plans, and what v1 stored under its own names. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/locked-payment-methods-for-woocommerce/includes/grants.php b/plugins/locked-payment-methods-for-woocommerce/includes/grants.php index 93eebb0..52ed497 100644 --- a/plugins/locked-payment-methods-for-woocommerce/includes/grants.php +++ b/plugins/locked-payment-methods-for-woocommerce/includes/grants.php @@ -32,7 +32,6 @@ */ function dws_lpmwc_get_user_grants_key(): string { global $wpdb; - return $wpdb->get_blog_prefix() . DWS_LPMWC_USER_OPTION_UNLOCKED_METHODS; } @@ -48,7 +47,6 @@ function dws_lpmwc_get_user_grants_key(): string { */ function dws_lpmwc_get_user_unlocked_payment_method_ids( int $user_id ): array { $ids = get_user_meta( $user_id, dws_lpmwc_get_user_grants_key(), true ); - return is_array( $ids ) ? array_values( array_filter( $ids, 'is_string' ) ) : array(); } @@ -75,7 +73,6 @@ function dws_lpmwc_get_user_unlocked_payment_method_ids( int $user_id ): array { function dws_lpmwc_get_order_unlocked_payment_method_ids( int $order_id ): array { $order = 0 === $order_id ? false : wc_get_order( $order_id ); $ids = $order instanceof WC_Order ? $order->get_meta( DWS_LPMWC_META_UNLOCKED_METHODS ) : array(); - return is_array( $ids ) ? array_values( array_filter( $ids, 'is_string' ) ) : array(); } @@ -91,7 +88,6 @@ function dws_lpmwc_get_order_unlocked_payment_method_ids( int $order_id ): array */ function dws_lpmwc_get_payment_method_title( string $gateway_id ): string { $gateway = WC()->payment_gateways()->payment_gateways()[ $gateway_id ] ?? null; - return $gateway instanceof WC_Payment_Gateway ? $gateway->get_title() : $gateway_id; } @@ -166,13 +162,14 @@ function dws_lpmwc_get_groups_unlocked_payment_method_ids(): array { */ function dws_lpmwc_get_plan_unlocked_payment_method_ids( int $plan_id ): array { $ids = get_post_meta( $plan_id, DWS_LPMWC_META_UNLOCKED_METHODS, true ); - return is_array( $ids ) ? array_values( array_filter( $ids, 'is_string' ) ) : array(); } /** * Returns whether a payment method is locked for a user and an order: whether the locked list holds it and dws_lpmwc/payment_method/should_lock does not relax it. * + * @api + * * @since 2.0.0 * @version 2.0.0 * diff --git a/plugins/locked-payment-methods-for-woocommerce/includes/metadata.php b/plugins/locked-payment-methods-for-woocommerce/includes/metadata.php index c08dd86..3e99b32 100644 --- a/plugins/locked-payment-methods-for-woocommerce/includes/metadata.php +++ b/plugins/locked-payment-methods-for-woocommerce/includes/metadata.php @@ -1,8 +1,8 @@ 'Plugin Name' ) )['Name']; - return 0 < did_action( 'init' ) ? __( $name, 'locked-payment-methods-for-woocommerce' ) : $name; // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- The header's name reaches the POT through make-pot's header extraction. } /** * Returns the plugin's version from its header. * + * @api + * * @since 2.0.0 * @version 2.0.0 * diff --git a/plugins/locked-payment-methods-for-woocommerce/includes/migrations.php b/plugins/locked-payment-methods-for-woocommerce/includes/migrations.php index 9308ff5..122390d 100644 --- a/plugins/locked-payment-methods-for-woocommerce/includes/migrations.php +++ b/plugins/locked-payment-methods-for-woocommerce/includes/migrations.php @@ -29,7 +29,9 @@ function dws_lpmwc_get_migration_steps(): array { } /** - * Stores each setting the site has not stored yet, autoloaded, from v1's row where it holds a value of the setting's kind and otherwise from the setting's default, and leaves v1's rows in place, so a site that goes back to v1 still finds its settings. + * Stores each setting the site has not stored yet. + * + * It takes v1's row where that holds a value of the setting's kind, and the setting's default otherwise, and leaves v1's rows in place, so a site that goes back to v1 still finds its settings. * * @since 2.0.0 * @version 2.0.0 @@ -46,11 +48,11 @@ function dws_lpmwc_add_settings(): void { } $default = DWS_LPMWC_OPTION_DEFAULTS[ $key ]; - $v1 = get_option( "dws-wc-lpm_{$group}_{$id}", null ); + $v1 = get_option( "dws-wc-lpm_{$group}_$id", null ); if ( is_array( $default ) ) { $value = is_array( $v1 ) ? array_values( array_filter( $v1, 'is_string' ) ) : $default; } else { - $value = wc_bool_to_string( null === $v1 ? $default : ( filter_var( $v1, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ?? $default ) ); // filter_var() reads null as false, so an absent row takes the default before it. + $value = wc_bool_to_string( is_null( $v1 ) ? $default : ( filter_var( $v1, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ?? $default ) ); // filter_var() reads null as false, so an absent row takes the default before it. } if ( ! add_option( $key, $value, '', true ) ) { @@ -149,7 +151,7 @@ function dws_lpmwc_move_order_grants(): void { foreach ( $tables as $table => $column ) { $wpdb->flush(); $listed = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- The order tables exist only once WooCommerce created them. - if ( null === $listed && '' !== $wpdb->last_error ) { + if ( is_null( $listed ) && '' !== $wpdb->last_error ) { throw new RuntimeException( 'The v1 order grants could not be read.' ); } if ( $table !== $listed ) { diff --git a/plugins/locked-payment-methods-for-woocommerce/includes/options.php b/plugins/locked-payment-methods-for-woocommerce/includes/options.php index 1870b40..2b4ade1 100644 --- a/plugins/locked-payment-methods-for-woocommerce/includes/options.php +++ b/plugins/locked-payment-methods-for-woocommerce/includes/options.php @@ -8,8 +8,6 @@ * @package DeepWebSolutions\LockedPaymentMethods */ -use DeepWebSolutions\LockedPaymentMethods\Plugin; - defined( 'ABSPATH' ) || exit; /** @@ -20,7 +18,7 @@ * * @var string */ -const DWS_LPMWC_OPTION_DELETE_DATA = Plugin::PREFIX . '_delete_data'; +const DWS_LPMWC_OPTION_DELETE_DATA = 'dws_lpmwc_delete_data'; /** * The option that holds the ids of the payment methods locked for every customer whom no unlock strategy unlocks. @@ -30,7 +28,7 @@ * * @var string */ -const DWS_LPMWC_OPTION_LOCKED_METHODS = Plugin::PREFIX . '_locked_payment_methods'; +const DWS_LPMWC_OPTION_LOCKED_METHODS = 'dws_lpmwc_locked_payment_methods'; /** * The option that holds 'yes' where users with the full-access roles may use every locked payment method. @@ -40,7 +38,7 @@ * * @var string */ -const DWS_LPMWC_OPTION_UNLOCK_VIA_USER_ROLES = Plugin::PREFIX . '_unlock_via_user_roles'; +const DWS_LPMWC_OPTION_UNLOCK_VIA_USER_ROLES = 'dws_lpmwc_unlock_via_user_roles'; /** * The option that holds the roles whose users may use every locked payment method while that strategy is on. @@ -50,7 +48,7 @@ * * @var string */ -const DWS_LPMWC_OPTION_FULL_ACCESS_ROLES = Plugin::PREFIX . '_full_access_user_roles'; +const DWS_LPMWC_OPTION_FULL_ACCESS_ROLES = 'dws_lpmwc_full_access_user_roles'; /** * The option that holds 'yes' where a user's profile can unlock locked payment methods for that user. @@ -60,7 +58,7 @@ * * @var string */ -const DWS_LPMWC_OPTION_UNLOCK_VIA_USER_PROFILES = Plugin::PREFIX . '_unlock_via_user_profiles'; +const DWS_LPMWC_OPTION_UNLOCK_VIA_USER_PROFILES = 'dws_lpmwc_unlock_via_user_profiles'; /** * The option that holds 'yes' where an order can unlock locked payment methods for paying that order. @@ -70,7 +68,7 @@ * * @var string */ -const DWS_LPMWC_OPTION_UNLOCK_VIA_ORDERS = Plugin::PREFIX . '_unlock_via_orders'; +const DWS_LPMWC_OPTION_UNLOCK_VIA_ORDERS = 'dws_lpmwc_unlock_via_orders'; /** * The option that holds 'yes' where a Groups group can unlock locked payment methods for its members. @@ -80,7 +78,7 @@ * * @var string */ -const DWS_LPMWC_OPTION_UNLOCK_VIA_GROUPS = Plugin::PREFIX . '_unlock_via_groups'; +const DWS_LPMWC_OPTION_UNLOCK_VIA_GROUPS = 'dws_lpmwc_unlock_via_groups'; /** * The option that holds 'yes' where a WooCommerce Memberships plan can unlock locked payment methods for its active members. @@ -90,7 +88,7 @@ * * @var string */ -const DWS_LPMWC_OPTION_UNLOCK_VIA_WC_MEMBERSHIPS = Plugin::PREFIX . '_unlock_via_wc_memberships'; +const DWS_LPMWC_OPTION_UNLOCK_VIA_WC_MEMBERSHIPS = 'dws_lpmwc_unlock_via_wc_memberships'; /** * The value each option reads as while nothing valid is stored. @@ -148,7 +146,6 @@ */ function dws_lpmwc_get_bool_option( string $key ): bool { $default = dws_lpmwc_get_option_default( $key ); - return filter_var( get_option( $key, $default ), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ?? ( true === $default ); } @@ -164,12 +161,11 @@ function dws_lpmwc_get_bool_option( string $key ): bool { */ function dws_lpmwc_get_int_option( string $key ): int { $default = dws_lpmwc_get_option_default( $key ); - return filter_var( get_option( $key, $default ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? ( is_int( $default ) ? $default : 0 ); } /** - * Returns an option as a string, reading a missing or empty value of an option with a translated default as that default, only after init. + * Returns an option as a string, reading a missing or empty value of an option with a translated default as that default. * * @since 2.0.0 * @version 2.0.0 @@ -203,12 +199,13 @@ function dws_lpmwc_get_list_option( string $key ): array { $default = dws_lpmwc_get_option_default( $key ); $stored = get_option( $key, $default ); $stored = is_array( $stored ) ? $stored : $default; - return is_array( $stored ) ? array_values( array_filter( $stored, 'is_string' ) ) : array(); } /** - * Returns an option's translated default, which exists only after init, or an empty string for an option without one. + * Returns an option's translated default, or an empty string for an option without one. + * + * Callers read it from init on, because WordPress flags translations loaded earlier. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/locked-payment-methods-for-woocommerce/includes/safeguards.php b/plugins/locked-payment-methods-for-woocommerce/includes/safeguards.php index fd0384b..da23dc0 100644 --- a/plugins/locked-payment-methods-for-woocommerce/includes/safeguards.php +++ b/plugins/locked-payment-methods-for-woocommerce/includes/safeguards.php @@ -1,6 +1,8 @@ query_vars['order-pay'] ?? 0 ); $key = isset( $_GET['key'] ) && is_string( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The order key authorizes paying the order, as on WooCommerce's order-pay page. - return $order instanceof WC_Order && '' !== $key && hash_equals( $order->get_order_key(), $key ) ? $order->get_id() : 0; } diff --git a/plugins/locked-payment-methods-for-woocommerce/locked-payment-methods-for-woocommerce.php b/plugins/locked-payment-methods-for-woocommerce/locked-payment-methods-for-woocommerce.php index ea15055..2924a99 100644 --- a/plugins/locked-payment-methods-for-woocommerce/locked-payment-methods-for-woocommerce.php +++ b/plugins/locked-payment-methods-for-woocommerce/locked-payment-methods-for-woocommerce.php @@ -1,5 +1,7 @@ getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The plugin's logger is part of the build that failed. + // A partial deploy fails the same way on every request, so each distinct message is logged about once an hour, as best effort. + $key = 'dws_lpmwc_build_error_' . md5( $error->getMessage() ); + if ( false === get_transient( $key ) ) { + set_transient( $key, 1, HOUR_IN_SECONDS ); + error_log( 'Locked Payment Methods for WooCommerce did not finish loading: ' . $error->getMessage() . ' in ' . $error->getFile() . ':' . $error->getLine() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The plugin's logger is part of the build that failed. + } + add_action( 'all_admin_notices', 'dws_lpmwc_render_build_notice' ); } diff --git a/plugins/locked-payment-methods-for-woocommerce/package.json b/plugins/locked-payment-methods-for-woocommerce/package.json index 6da8c24..aae4296 100644 --- a/plugins/locked-payment-methods-for-woocommerce/package.json +++ b/plugins/locked-payment-methods-for-woocommerce/package.json @@ -26,27 +26,47 @@ "setup": "npm run packages-install:all && npm run build && npm run wp-env:start", "packages-install:all": "composer packages-install && npm run packages-install", "packages-update:all": "composer packages-update && npm run packages-update && npm run packages-update:wp", + "packages-install": "npm install", "packages-update": "npm update", "packages-update:wp": "wp-scripts packages-update", - "wp-env": "wp-env", + + "build": "exit 0", + + "format": "npm run format:scripts && npm run format:styles", + "format:scripts": "wp-scripts format tests/EndToEnd eslint.config.mjs stylelint.config.js playwright.config.js", + "format:styles": "npm run lint:styles -- --fix", + "format:all": "( composer format:php || [ $? -eq 1 ] ) && npm run format", + + "lint": "npm run lint:scripts && npm run lint:styles && npm run lint:types", + "lint:scripts": "wp-scripts lint-js tests/EndToEnd eslint.config.mjs stylelint.config.js playwright.config.js", + "lint:styles": "wp-scripts lint-style 'assets/src/**/*.scss' --allow-empty-input", + "lint:types": "tsc", + "lint:all": "composer lint:php && npm run lint", + "wp-env:start": "wp-env start", "wp-env:stop": "wp-env stop", "wp-env:update": "wp-env start --update", "wp-env:destroy": "wp-env destroy", + "wp-env:tests:start": "wp-env --config .wp-env.tests.json start", "wp-env:tests:stop": "wp-env --config .wp-env.tests.json stop", "wp-env:tests:update": "wp-env --config .wp-env.tests.json start --update", "wp-env:tests:destroy": "wp-env --config .wp-env.tests.json destroy", - "build": "exit 0", - "lint": "npm run lint:scripts && npm run lint:styles && npm run lint:types", - "lint:scripts": "wp-scripts lint-js tests/EndToEnd", - "lint:styles": "wp-scripts lint-style 'assets/src/**/*.scss' --allow-empty-input", - "lint:types": "tsc", - "audit": "composer audit && npm audit", + + "wp-env:multisite:start": "wp-env --config .wp-env.multisite.json start", + "wp-env:multisite:stop": "wp-env --config .wp-env.multisite.json stop", + "wp-env:multisite:update": "wp-env --config .wp-env.multisite.json start --update", + "wp-env:multisite:destroy": "wp-env --config .wp-env.multisite.json destroy", + + "audit": "composer audit --locked --abandoned=report && npm audit --omit=dev --audit-level=high", "audit:fix": "npm audit fix", "check:engines": "wp-scripts check-engines", "check:licenses": "wp-scripts check-licenses", - "test:e2e": "playwright test --config=playwright.config.js" + + "test:e2e": "playwright test --config=playwright.config.js", + "test:all": "composer test && npm run test:e2e", + + "quality-check:all": "composer quality-check && npm run lint && npm run test:e2e" } } diff --git a/plugins/locked-payment-methods-for-woocommerce/phpcs.dist.xml b/plugins/locked-payment-methods-for-woocommerce/phpcs.dist.xml index cc94b8b..4a41cb2 100644 --- a/plugins/locked-payment-methods-for-woocommerce/phpcs.dist.xml +++ b/plugins/locked-payment-methods-for-woocommerce/phpcs.dist.xml @@ -11,4 +11,14 @@ + + + + + + + + + + diff --git a/plugins/locked-payment-methods-for-woocommerce/phpstan.dist.neon b/plugins/locked-payment-methods-for-woocommerce/phpstan.dist.neon index 95fdd84..a6aa3dd 100644 --- a/plugins/locked-payment-methods-for-woocommerce/phpstan.dist.neon +++ b/plugins/locked-payment-methods-for-woocommerce/phpstan.dist.neon @@ -1,4 +1,4 @@ -# The WordPress extensions are listed here because composer.json keeps them from installing themselves, and before configs' profile because wp-compat's neon resets WPCompat.requiresAtLeast to null. +# The WordPress extensions are listed here because composer.json keeps them from installing themselves. includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon - vendor/johnbillion/wp-compat/extension.neon @@ -7,20 +7,24 @@ includes: parameters: paths: - - locked-payment-methods-for-woocommerce.php - functions.php - uninstall.php - includes - src - # phpstan.safeguards.neon analyzes the safeguards at the oldest runtime they parse on. + # phpstan.safeguards.neon analyzes the entry and the safeguards at the oldest runtime they parse on, so this profile only scans them. excludePaths: - includes/safeguards.php scanFiles: + - locked-payment-methods-for-woocommerce.php - includes/safeguards.php - - tests/PHPStan/woocommerce-memberships.php - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php - vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php + - tests/PHPStan/woocommerce-memberships.php # The scoped packages load through vendor-prefixed/scoper-autoload.php, which PHPStan does not read. scanDirectories: - vendor-prefixed - vendor/wp-plugin/groups/lib + # wp-compat reads the floor from the entry's Requires at least only while requiresAtLeast, which configs' profile sets, is null. + WPCompat: + pluginFile: locked-payment-methods-for-woocommerce.php + requiresAtLeast: null diff --git a/plugins/locked-payment-methods-for-woocommerce/phpstan.safeguards.neon b/plugins/locked-payment-methods-for-woocommerce/phpstan.safeguards.neon index 5868d66..9cadf54 100644 --- a/plugins/locked-payment-methods-for-woocommerce/phpstan.safeguards.neon +++ b/plugins/locked-payment-methods-for-woocommerce/phpstan.safeguards.neon @@ -1,4 +1,4 @@ -# The safeguards load before the floor gate, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on, which the framework's phpstan.bootstrap.neon sets; the WordPress extensions are listed because composer.json keeps them from installing themselves. +# The entry and the safeguards run before the floor gate, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on, which the framework's phpstan.bootstrap.neon sets; the WordPress extensions are listed because composer.json keeps them from installing themselves. includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon - vendor/johnbillion/wp-compat/extension.neon @@ -7,10 +7,20 @@ includes: parameters: paths: + - locked-payment-methods-for-woocommerce.php - includes/safeguards.php - # The lock filters WooCommerce's gateways, so it calls WooCommerce too. + # A safeguard calls WooCommerce where the plugin requires it. scanFiles: - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php phpVersion: 70400 WPCompat: requiresAtLeast: '5.2' + # The entry loads the scoped bootstrap package by path, before any autoloader. + scanDirectories: + - vendor-prefixed/ahegyes/wp-framework-bootstrap + ignoreErrors: + # The entry's build-error handler runs only once the floor gate has passed. + - + identifier: WPCompat.functionNotAvailable + message: '#^wp_get_environment_type\(\) #' + path: locked-payment-methods-for-woocommerce.php diff --git a/plugins/locked-payment-methods-for-woocommerce/phpunit.dist.xml b/plugins/locked-payment-methods-for-woocommerce/phpunit.dist.xml index c04a1e5..85246b7 100644 --- a/plugins/locked-payment-methods-for-woocommerce/phpunit.dist.xml +++ b/plugins/locked-payment-methods-for-woocommerce/phpunit.dist.xml @@ -27,6 +27,7 @@ src + includes diff --git a/plugins/locked-payment-methods-for-woocommerce/playwright.config.js b/plugins/locked-payment-methods-for-woocommerce/playwright.config.js index 59effb6..e1fdf65 100644 --- a/plugins/locked-payment-methods-for-woocommerce/playwright.config.js +++ b/plugins/locked-payment-methods-for-woocommerce/playwright.config.js @@ -1,6 +1,8 @@ const { defineConfig } = require( '@playwright/test' ); -// The port of .wp-env.tests.json, so the suite runs on the production tests environment and leaves the development one's data alone. -const baseConfig = require( '@ahegyes/wordpress-configs/node/playwright.config.base.js' )( { port: 8827 } ); +const baseConfig = + require( '@ahegyes/wordpress-configs/node/playwright.config.base.js' )( { + port: require( './.wp-env.tests.json' ).port, + } ); module.exports = defineConfig( { ...baseConfig, diff --git a/plugins/locked-payment-methods-for-woocommerce/scoper.inc.php b/plugins/locked-payment-methods-for-woocommerce/scoper.inc.php index 11b1f9b..d49a0ee 100644 --- a/plugins/locked-payment-methods-for-woocommerce/scoper.inc.php +++ b/plugins/locked-payment-methods-for-woocommerce/scoper.inc.php @@ -2,6 +2,9 @@ /** * Returns the php-scoper config that prefixes the framework and PHP-DI with the plugin's namespace. * + * @since 2.0.0 + * @version 2.0.0 + * * @package DeepWebSolutions\LockedPaymentMethods */ diff --git a/plugins/locked-payment-methods-for-woocommerce/src/EditScreen/Hosts/index.php b/plugins/locked-payment-methods-for-woocommerce/src/EditScreen/Hosts/index.php new file mode 100644 index 0000000..f767346 --- /dev/null +++ b/plugins/locked-payment-methods-for-woocommerce/src/EditScreen/Hosts/index.php @@ -0,0 +1 @@ +logger = new WooCommerceLogger( self::SLUG, \defined( 'DWS_LPMWC_DEBUG' ) && true === \constant( 'DWS_LPMWC_DEBUG' ) ? 'debug' : null ); $this->scheduler = new Scheduler( self::PREFIX ); - $this->boot = new PluginBoot( DWS_LPMWC_FILE, self::PREFIX, $this->logger ); + $this->boundary = new PluginBoot( DWS_LPMWC_FILE, self::PREFIX, $this->logger ); } // endregion @@ -149,7 +149,7 @@ public function __construct() { * @return void */ public function boot(): void { - $this->boot->run( $this->compose( ... ) ); + $this->boundary->run( $this->compose( ... ) ); } /** @@ -201,7 +201,6 @@ protected function compose(): bool { $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); - return true; } diff --git a/plugins/locked-payment-methods-for-woocommerce/src/Settings.php b/plugins/locked-payment-methods-for-woocommerce/src/Settings.php index 4d2833b..86ccebc 100644 --- a/plugins/locked-payment-methods-for-woocommerce/src/Settings.php +++ b/plugins/locked-payment-methods-for-woocommerce/src/Settings.php @@ -82,15 +82,15 @@ public function get_section_fields( string $tab_id, string $section_id ): array $fields[] = array( 'type' => 'title', - 'id' => "dws-wc-lpm_{$group}", + 'id' => "dws-wc-lpm_$group", 'title' => $titles[ $group ], ); foreach ( $group_fields as $id => $field ) { - $fields[] = array( 'id' => $keys[ $id ] ?? "dws-wc-lpm_{$group}_{$id}" ) + $field; // A field a v1 callback adds keeps the option v1 stored it under. + $fields[] = array( 'id' => $keys[ $id ] ?? "dws-wc-lpm_{$group}_$id" ) + $field; // A field a v1 callback adds keeps the option v1 stored it under. } $fields[] = array( 'type' => 'sectionend', - 'id' => "dws-wc-lpm_{$group}", + 'id' => "dws-wc-lpm_$group", ); } @@ -136,7 +136,6 @@ public function register_section( SettingsSectionRegistry $registry ): void { */ public function add_settings_link( array $links ): array { \array_unshift( $links, \sprintf( '%s', esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=' . self::SECTION ) ), esc_html__( 'Settings', 'locked-payment-methods-for-woocommerce' ) ) ); - return $links; } diff --git a/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/GroupsStrategy.php b/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/GroupsStrategy.php index 69b5ee5..b51250c 100644 --- a/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/GroupsStrategy.php +++ b/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/GroupsStrategy.php @@ -105,15 +105,12 @@ public function register_hooks(): void { */ public static function is_available(): bool { $version = \defined( 'GROUPS_CORE_VERSION' ) ? \constant( 'GROUPS_CORE_VERSION' ) : null; - return \is_string( $version ) && \class_exists( \Groups_User::class ) && \version_compare( $version, '2.11.0', '>=' ); } /** * Returns whether a group of the user, or a group above one, unlocks the payment method. * - * @internal - * * @since 2.0.0 * @version 2.0.0 * @@ -128,7 +125,7 @@ public static function unlocks( string $gateway_id, int $user_id ): bool { } $groups = dws_lpmwc_get_groups_unlocked_payment_method_ids(); - foreach ( (array) ( new \Groups_User( $user_id ) )->group_ids_deep as $group_id ) { // @phpstan-ignore property.notFound (Groups_User resolves its group ids through __get, as at v1's oldest Groups.) + foreach ( (array) new \Groups_User( $user_id )->group_ids_deep as $group_id ) { // @phpstan-ignore property.notFound (Groups_User resolves its group ids through __get, as at v1's oldest Groups.) if ( \is_numeric( $group_id ) && \in_array( $gateway_id, $groups[ (int) $group_id ] ?? array(), true ) ) { return true; } @@ -175,7 +172,6 @@ public function render_group_field( string $output, int $group_id = 0 ): string } $value = dws_lpmwc_get_groups_unlocked_payment_method_ids()[ $group_id ] ?? array(); - return $output . '

    ' . esc_html( $field['title'] ) . '

    ' . Forms::render_control( $field, $value, self::ENTRY . '[' . self::FIELD . ']', self::FIELD ) . '
    '; } diff --git a/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/OrderStrategy.php b/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/OrderStrategy.php index 2dce681..04c1af7 100644 --- a/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/OrderStrategy.php +++ b/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/OrderStrategy.php @@ -109,8 +109,6 @@ public function get_object_form( string $form_id, int $object_id ): array { /** * Returns whether the order's grants list holds the payment method. * - * @internal - * * @since 2.0.0 * @version 2.0.0 * diff --git a/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/UserProfileStrategy.php b/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/UserProfileStrategy.php index e258854..0d63575 100644 --- a/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/UserProfileStrategy.php +++ b/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/UserProfileStrategy.php @@ -97,7 +97,6 @@ public function register_hooks(): void { #[\Override] public function get_object_form( string $form_id, int $object_id ): array { $options = dws_lpmwc_get_grant_choices( dws_lpmwc_get_user_unlocked_payment_method_ids( $object_id ), $object_id ); - return array( 'title' => __( 'Locked Payment Methods', 'locked-payment-methods-for-woocommerce' ), 'capability' => DWS_LPMWC_CAP_UNLOCK_VIA_USER_PROFILES, @@ -120,8 +119,6 @@ public function get_object_form( string $form_id, int $object_id ): array { /** * Returns whether the user's grants list on the current site holds the payment method. * - * @internal - * * @since 2.0.0 * @version 2.0.0 * @@ -167,7 +164,6 @@ public function unlock_granted_methods( mixed $lock, string $gateway_id, int $us public function add_users_column( array $columns ): array { $position = \array_search( 'role', \array_keys( $columns ), true ); $position = false === $position ? \count( $columns ) : $position + 1; - return \array_slice( $columns, 0, $position, true ) + array( self::COLUMN => __( 'Unlocked for', 'locked-payment-methods-for-woocommerce' ) ) + \array_slice( $columns, $position, null, true ); } @@ -253,7 +249,6 @@ public function filter_users( array $args ): array { 'compare' => 'LIKE', ); $args['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- The filter matches a user meta value, which only a meta query reaches. - return $args; } diff --git a/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/UserRoleStrategy.php b/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/UserRoleStrategy.php index ae71926..1a7eb1e 100644 --- a/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/UserRoleStrategy.php +++ b/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/UserRoleStrategy.php @@ -45,8 +45,6 @@ public function register_hooks(): void { /** * Returns whether the user holds one of the full-access roles, which unlocks every locked payment method. * - * @internal - * * @since 2.0.0 * @version 2.0.0 * @@ -62,7 +60,6 @@ public static function unlocks( string $gateway_id, int $user_id ): bool { } $roles = dws_lpmwc_apply_v1_setting_filter( dws_lpmwc_get_list_option( DWS_LPMWC_OPTION_FULL_ACCESS_ROLES ), 'general', 'full-access-user-roles', 'option_' . DWS_LPMWC_OPTION_FULL_ACCESS_ROLES ); - return \is_array( $roles ) && array() !== \array_intersect( $user->roles, \array_filter( $roles, 'is_string' ) ); } diff --git a/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/WooCommerceMembershipsStrategy.php b/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/WooCommerceMembershipsStrategy.php index 72cdf5d..096a873 100644 --- a/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/WooCommerceMembershipsStrategy.php +++ b/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/WooCommerceMembershipsStrategy.php @@ -83,7 +83,6 @@ public function register_hooks(): void { #[\Override] public function get_object_form( string $form_id, int $object_id ): array { $options = dws_lpmwc_get_grant_choices( dws_lpmwc_get_plan_unlocked_payment_method_ids( $object_id ), 0 ); - return array( 'title' => __( 'Unlocked Payment Methods', 'locked-payment-methods-for-woocommerce' ), 'capability' => DWS_LPMWC_CAP_UNLOCK_VIA_WC_MEMBERSHIPS, @@ -113,15 +112,12 @@ public function get_object_form( string $form_id, int $object_id ): array { */ public static function is_available(): bool { $version = get_option( 'wc_memberships_version', '0.0.0' ); - return \function_exists( 'wc_memberships_get_user_active_memberships' ) && \is_string( $version ) && \version_compare( $version, '1.18.0', '>=' ); } /** * Returns whether a plan the user is an active member of unlocks the payment method. * - * @internal - * * @since 2.0.0 * @version 2.0.0 * diff --git a/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/index.php b/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/index.php new file mode 100644 index 0000000..f767346 --- /dev/null +++ b/plugins/locked-payment-methods-for-woocommerce/src/UnlockStrategies/index.php @@ -0,0 +1 @@ + -import { execFileSync } from 'node:child_process'; import { expect, test } from '@wordpress/e2e-test-utils-playwright'; - -// No screen enables a payment method, locks it or grants it in one step, so the site's WP-CLI stores them. -const cli = ( ...args: string[] ): string => - execFileSync( - 'node_modules/.bin/wp-env', - [ '--config', '.wp-env.tests.json', 'run', 'cli', 'wp', ...args ], - { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] } - ) - .trim() - .split( '\n' ) - .pop() ?? ''; +import { cli } from './cli'; // A context takes the config's storage state, the administrator's session, unless it names its own. const LOGGED_OUT = { cookies: [], origins: [] }; diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/EndToEnd/cli.ts b/plugins/locked-payment-methods-for-woocommerce/tests/EndToEnd/cli.ts new file mode 100644 index 0000000..14aabda --- /dev/null +++ b/plugins/locked-payment-methods-for-woocommerce/tests/EndToEnd/cli.ts @@ -0,0 +1,13 @@ +/// +import { execFileSync } from 'node:child_process'; + +// No screen shows some of what the specs set up or check, so they run WP-CLI on the tests site and read its last line. +export const cli = ( ...args: string[] ): string => + execFileSync( + 'node_modules/.bin/wp-env', + [ '--config', '.wp-env.tests.json', 'run', 'cli', 'wp', ...args ], + { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] } + ) + .trim() + .split( '\n' ) + .pop() ?? ''; diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/EndToEnd/without-woocommerce.spec.ts b/plugins/locked-payment-methods-for-woocommerce/tests/EndToEnd/without-woocommerce.spec.ts index cd51443..b566b62 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/EndToEnd/without-woocommerce.spec.ts +++ b/plugins/locked-payment-methods-for-woocommerce/tests/EndToEnd/without-woocommerce.spec.ts @@ -1,30 +1,10 @@ -/// -import { execFileSync } from 'node:child_process'; import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +import { cli } from './cli'; // WordPress core's own message for a plugin whose Requires Plugins header lists an inactive plugin. const DEPENDENCY = 'Locked Payment Methods for WooCommerce requires 1 plugin to be installed and activated: WooCommerce.'; -// No page shows the boot's outcome, so the site's WP-CLI reads it. -const bootStatus = (): string => - execFileSync( - 'node_modules/.bin/wp-env', - [ - '--config', - '.wp-env.tests.json', - 'run', - 'cli', - 'wp', - 'eval', - 'echo dws_lpmwc_plugin()->boot->status;', - ], - { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] } - ) - .trim() - .split( '\n' ) - .pop() ?? ''; - test.describe( 'Without WooCommerce', () => { test.beforeAll( async ( { requestUtils } ) => { await requestUtils.deactivatePlugin( 'woocommerce' ); @@ -37,7 +17,9 @@ test.describe( 'Without WooCommerce', () => { test( 'the plugin stays blocked, WordPress names the missing plugin and every screen still loads', async ( { page, } ) => { - expect( bootStatus() ).toBe( 'blocked' ); + expect( + cli( 'eval', 'echo dws_lpmwc_plugin()->boundary->status;' ) + ).toBe( 'blocked' ); const front = await page.goto( '/' ); expect( front?.status() ).toBe( 200 ); diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/DeprecatedApiTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/DeprecatedApiTest.php index 7cddae8..2f38617 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/DeprecatedApiTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/DeprecatedApiTest.php @@ -2,7 +2,8 @@ namespace DeepWebSolutions\LockedPaymentMethods\Tests\Integration; -use PHPUnit\Framework\Attributes\After; +use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RecordsDeprecationsTrait; +use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; @@ -10,13 +11,12 @@ #[CoversNothing] final class DeprecatedApiTest extends TestCase { - protected const array KEYS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_FULL_ACCESS_ROLES, DWS_LPMWC_OPTION_UNLOCK_VIA_USER_ROLES, DWS_LPMWC_OPTION_UNLOCK_VIA_ORDERS ); + use RecordsDeprecationsTrait; + use RestoresOptionsTrait; - protected static array $users = array(); - - protected array $stored = array(); + protected const array OPTIONS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_FULL_ACCESS_ROLES, DWS_LPMWC_OPTION_UNLOCK_VIA_USER_ROLES, DWS_LPMWC_OPTION_UNLOCK_VIA_ORDERS ); - protected array $deprecated = array(); + protected static array $users = array(); public static function hook_tags(): array { return array( @@ -54,31 +54,9 @@ public static function tearDownAfterClass(): void { } #[Before] - protected function record_deprecations(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } + protected function store_the_settings(): void { update_option( DWS_LPMWC_OPTION_LOCKED_METHODS, array( 'cod' ) ); update_option( DWS_LPMWC_OPTION_FULL_ACCESS_ROLES, array( 'shop_manager' ) ); - add_filter( 'deprecated_function_trigger_error', '__return_false' ); - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_function_run', array( $this, 'record' ), 10, 2 ); - add_action( 'deprecated_hook_run', array( $this, 'record' ), 10, 2 ); - } - - #[After] - protected function stop_recording(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - remove_filter( 'deprecated_function_trigger_error', '__return_false' ); - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_function_run', array( $this, 'record' ) ); - remove_action( 'deprecated_hook_run', array( $this, 'record' ) ); - } - - public function record( string $name, string $replacement ): void { - $this->deprecated[] = array( $name, $replacement ); } public function test_the_v1_getters_answer_from_the_plugin_header(): void { @@ -131,13 +109,16 @@ public function test_a_v1_hook_tag_builds_as_v1_built_it( string $shim, array $a self::assertSame( array( array( $shim, '' ) ), $this->deprecated ); } - public function test_the_v1_boot_hooks_fire_after_the_v2_ones_with_the_failure(): void { + // The v1 hooks fire at the earliest priority, so every v1 listener runs before a v2 listener of any later one. + public function test_the_v1_boot_hooks_fire_at_the_earliest_priority_with_the_failure(): void { $fired = array(); $spy = static function ( mixed $error = null ) use ( &$fired ): void { $fired[] = array( current_action(), $error ); }; add_action( 'dws_lpmwc_initialized', $spy ); add_action( 'dws_lpmwc_initialization_failure', $spy ); + add_action( 'dws_lpmwc/boot/succeeded', $spy, PHP_INT_MIN + 1 ); + add_action( 'dws_lpmwc/boot/failed', $spy, PHP_INT_MIN + 1 ); $error = new \RuntimeException( 'Failed.' ); try { @@ -146,12 +127,16 @@ public function test_the_v1_boot_hooks_fire_after_the_v2_ones_with_the_failure() } finally { remove_action( 'dws_lpmwc_initialized', $spy ); remove_action( 'dws_lpmwc_initialization_failure', $spy ); + remove_action( 'dws_lpmwc/boot/succeeded', $spy, PHP_INT_MIN + 1 ); + remove_action( 'dws_lpmwc/boot/failed', $spy, PHP_INT_MIN + 1 ); } self::assertSame( array( array( 'dws_lpmwc_initialized', null ), + array( 'dws_lpmwc/boot/succeeded', '' ), array( 'dws_lpmwc_initialization_failure', $error ), + array( 'dws_lpmwc/boot/failed', $error ), ), $fired ); @@ -162,6 +147,8 @@ public function test_the_v1_boot_hooks_fire_after_the_v2_ones_with_the_failure() ), $this->deprecated ); + self::assertSame( PHP_INT_MIN, has_action( 'dws_lpmwc/boot/succeeded', 'dws_lpmwc_fire_v1_initialized' ) ); + self::assertSame( PHP_INT_MIN, has_action( 'dws_lpmwc/boot/failed', 'dws_lpmwc_fire_v1_initialization_failure' ) ); } public function test_a_payment_method_is_locked_for_a_user_when_it_is_listed_and_no_strategy_unlocks_it(): void { @@ -176,7 +163,6 @@ public function test_v1s_access_checks_answer_true_for_locked_from_the_strategie $seen = array(); $spy = static function ( mixed ...$args ) use ( &$seen ): mixed { $seen[] = $args; - return $args[0]; }; add_filter( $v1, $spy, 10, 3 ); @@ -200,7 +186,6 @@ public function test_v1s_order_access_check_judges_the_order_and_its_customer_af $seen = array(); $spy = static function ( mixed ...$args ) use ( &$seen ): mixed { $seen[] = $args; - return $args[0]; }; add_filter( $v1, $spy, 10, 3 ); diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/MetadataTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/MetadataTest.php index d542c6e..6d2953b 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/MetadataTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/MetadataTest.php @@ -51,7 +51,6 @@ protected static function get_listed_header( string $header ): string { protected static function translate_name(): \Closure { $name = self::get_listed_header( 'Name' ); - return static fn ( string $translation, string $text ): string => $name === $text ? self::TRANSLATION : $translation; } } diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/MigrationsTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/MigrationsTest.php index 45475dc..83a8a3f 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/MigrationsTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/MigrationsTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\LockedPaymentMethods\Plugin; use DeepWebSolutions\LockedPaymentMethods\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; +use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -11,6 +12,10 @@ #[CoversNothing] final class MigrationsTest extends TestCase { + use RestoresOptionsTrait; + + protected const array OPTIONS = array( ...self::KEYS, ...self::V1_KEYS ); + // v1.3.5's settings, as its free and premium builds stored them. protected const array V1_KEYS = array( 'dws-wc-lpm_general_locked-payment-methods', @@ -34,20 +39,8 @@ final class MigrationsTest extends TestCase { DWS_LPMWC_OPTION_DELETE_DATA, ); - protected array $stored = array(); - - #[\Override] - protected function setUp(): void { - foreach ( array( ...self::KEYS, ...self::V1_KEYS ) as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - } - #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } + protected function restore_the_revision(): void { update_option( Plugin::PREFIX . '_revision', \count( dws_lpmwc_get_migration_steps() ) ); } diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/MultisiteTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/MultisiteTest.php new file mode 100644 index 0000000..cbb087d --- /dev/null +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/MultisiteTest.php @@ -0,0 +1,41 @@ + get_network()?->domain, + 'path' => get_network()?->path . \str_replace( '_', '-', Plugin::PREFIX ) . '-second/', + ) + ); + self::assertIsInt( $site_id ); + + try { + // WooCommerce queries two of its tables during a new site's first init, before its version check installs them, so a request without the plugin installs WooCommerce first. + $installed = self::request_provoking( 'WordPress database error', 'echo (int) function_exists( "dws_lpmwc_boot" );', '--url=' . get_home_url( $site_id, '/' ), '--skip-plugins=' . Plugin::SLUG ); + self::assertSame( 0, $installed['status'], $installed['output'] ); + self::assertSame( '0', $installed['last'], $installed['output'] ); + + $request = self::request( 'echo did_action( "dws_lpmwc/boot/succeeded" );', '--url=' . get_home_url( $site_id, '/' ) ); + + self::assertSame( '1', $request['last'], $request['output'] ); + self::assertSame( 'no', get_blog_option( $site_id, DWS_LPMWC_OPTION_DELETE_DATA ) ); + self::assertTrue( update_blog_option( $site_id, DWS_LPMWC_OPTION_DELETE_DATA, 'yes' ) ); + self::assertSame( 'no', get_option( DWS_LPMWC_OPTION_DELETE_DATA ) ); + } finally { + wp_delete_site( $site_id ); + } + } +} diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/OptionsTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/OptionsTest.php index 9155a90..f1c0ca9 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/OptionsTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/OptionsTest.php @@ -3,19 +3,19 @@ namespace DeepWebSolutions\LockedPaymentMethods\Tests\Integration; use DeepWebSolutions\LockedPaymentMethods\Plugin; +use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversNothing] final class OptionsTest extends TestCase { - protected const string KEY = Plugin::PREFIX . '_options_test'; + use RestoresOptionsTrait; - protected const array RESTORED = array( DWS_LPMWC_OPTION_DELETE_DATA ); + protected const string KEY = Plugin::PREFIX . '_options_test'; - protected array $stored = array(); + protected const array OPTIONS = array( DWS_LPMWC_OPTION_DELETE_DATA ); public static function stored_bools(): array { return array( @@ -59,19 +59,9 @@ public static function stored_lists(): array { ); } - #[Before] - protected function store_rows(): void { - foreach ( self::RESTORED as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - } - #[After] - protected function restore_rows(): void { + protected function delete_the_test_option(): void { delete_option( self::KEY ); - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } } #[DataProvider( 'stored_bools' )] diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/PartialDeployTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/PartialDeployTest.php index e7b4b35..5e4c899 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/PartialDeployTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/PartialDeployTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\LockedPaymentMethods\Tests\Integration; +use DeepWebSolutions\LockedPaymentMethods\Plugin; use DeepWebSolutions\LockedPaymentMethods\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\CoversNothing; @@ -29,7 +30,7 @@ public function test_an_include_that_throws_shows_the_build_notice_and_boots_not $include = \dirname( DWS_LPMWC_FILE ) . '/includes/zz-throws.php'; \file_put_contents( $include, "boot->status ); + self::assertSame( 'booted', $plugin->boundary->status ); self::assertSame( array( OrderStrategy::class, GroupsStrategy::class, WooCommerceMembershipsStrategy::class ), $plugin->kernel->skipped ); // Orders and the integrations are off by default, as v1's were. } @@ -37,6 +37,19 @@ public function test_the_entry_loads_every_include_but_those_whose_names_start_w self::assertSame( 'skipped', $request['last'], $request['output'] ); } + public function test_a_boot_listener_that_throws_is_not_reported_as_an_incomplete_build(): void { + $include = \dirname( DWS_LPMWC_FILE ) . '/includes/zz-boot-listener.php'; + \file_put_contents( $include, " array( array(), false ), @@ -63,32 +65,18 @@ public static function floors(): array { } #[Before] - protected function store_rows(): void { - foreach ( array( DWS_LPMWC_OPTION_LOCKED_METHODS, self::V1_KEY ) as $key ) { - $this->stored[ $key ] = get_option( $key ); - } + protected function lock_cod(): void { update_option( DWS_LPMWC_OPTION_LOCKED_METHODS, array( 'cod' ) ); - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ), 10, 2 ); } #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ) ); + protected function reset_the_request(): void { remove_filter( 'woocommerce_is_checkout', '__return_true' ); unset( $GLOBALS['wp']->query_vars['order-pay'], $_GET['key'] ); set_query_var( 'order-pay', '' ); wp_set_current_user( 0 ); } - public function record_deprecated_hook( string $hook, string $replacement ): void { - $this->deprecated[] = array( $hook, $replacement ); - } - public function test_the_lock_removes_the_methods_the_plugins_option_lists(): void { $available = self::available(); @@ -222,7 +210,6 @@ public function test_the_locked_ids_filter_follows_v1s_value_filter_and_v1s_list foreach ( $hooks as $hook => $id ) { $adders[ $hook ] = static function ( array $ids, mixed $context = null ) use ( &$seen, $hook, $id ): array { $seen[] = array( $hook, $ids, \is_array( $context ) ? \array_keys( $context ) : $context ); - return array( ...$ids, $id ); }; add_filter( $hook, $adders[ $hook ], 10, 2 ); @@ -255,12 +242,10 @@ public function test_v1s_lock_filters_fire_as_deprecated_before_should_lock_unde $v1_safe = "$v1/woocommerce_payments"; $record = static function ( mixed ...$args ) use ( &$seen ): mixed { $seen[] = $args; - return $args[0]; }; $returns_null = static function ( mixed $lock ) use ( &$seen ): mixed { $seen[] = array( $lock ); - return null; }; add_filter( $v1, $record, 10, 2 ); @@ -349,7 +334,7 @@ public function test_should_lock_judges_no_order_on_an_order_pay_page_without_th #[DataProvider( 'floors' )] public function test_the_lock_holds_and_nothing_relaxes_it_while_the_plugin_cannot_boot( ?string $header, string $expected ): void { $source = (string) \file_get_contents( DWS_LPMWC_FILE ); - if ( null !== $header ) { + if ( ! \is_null( $header ) ) { \file_put_contents( DWS_LPMWC_FILE, (string) \preg_replace( '/^(\s*\*\s*' . \preg_quote( $header, '/' ) . ':\s*).*$/m', '${1}99.0', $source ) ); } @@ -391,8 +376,18 @@ protected static function visit_order_pay( int $order_id, ?string $key ): void { $GLOBALS['wp']->query_vars['order-pay'] = (string) $order_id; set_query_var( 'order-pay', (string) $order_id ); add_filter( 'woocommerce_is_checkout', '__return_true' ); - if ( null !== $key ) { + if ( ! \is_null( $key ) ) { $_GET['key'] = $key; } } + + // WordPress loads the plugin while WooCommerce is inactive, so a safeguard that calls WooCommerce waits for it to load. + public function test_the_gateway_lock_is_hooked_only_once_woocommerce_has_loaded(): void { + $php = 'echo wp_json_encode( array( did_action( "woocommerce_loaded" ), false !== has_filter( "woocommerce_available_payment_gateways", "dws_lpmwc_remove_locked_payment_methods" ) ) );'; + $with = self::request( $php ); + $without = self::request( $php, '--skip-plugins=woocommerce' ); + + self::assertSame( '[1,true]', $with['last'], $with['output'] ); + self::assertSame( '[0,false]', $without['last'], $without['output'] ); + } } diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/SettingsTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/SettingsTest.php index 4eefb9b..2154bf2 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/SettingsTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/SettingsTest.php @@ -3,27 +3,27 @@ namespace DeepWebSolutions\LockedPaymentMethods\Tests\Integration; use DeepWebSolutions\LockedPaymentMethods\Settings; +use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RecordsDeprecationsTrait; +use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RestoresOptionsTrait; use DeepWebSolutions\LockedPaymentMethods\UnlockStrategies\GroupsStrategy; use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( Settings::class )] final class SettingsTest extends TestCase { + use RecordsDeprecationsTrait; + use RestoresOptionsTrait; + protected const string SECTION = 'dws-locked-payment-methods'; protected const array CHECKBOXES = array( DWS_LPMWC_OPTION_UNLOCK_VIA_USER_ROLES, DWS_LPMWC_OPTION_UNLOCK_VIA_USER_PROFILES, DWS_LPMWC_OPTION_UNLOCK_VIA_ORDERS, DWS_LPMWC_OPTION_DELETE_DATA ); - protected const array KEYS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_FULL_ACCESS_ROLES, ...self::CHECKBOXES ); + protected const array OPTIONS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_FULL_ACCESS_ROLES, ...self::CHECKBOXES ); protected static \WC_Settings_Page $advanced; - protected array $stored = array(); - - protected array $deprecated = array(); - #[\Override] public static function setUpBeforeClass(): void { require_once WC_ABSPATH . 'includes/admin/class-wc-admin-settings.php'; @@ -43,27 +43,9 @@ public static function setting_groups(): array { ); } - #[Before] - protected function store_rows(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ), 10, 2 ); - } - #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - ( new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' ) )->setValue( null, array() ); - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ) ); - } - - public function record_deprecated_hook( string $hook, string $replacement ): void { - $this->deprecated[] = array( $hook, $replacement ); + protected function forget_the_rejections(): void { + new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' )->setValue( null, array() ); } public function test_the_section_sits_on_the_advanced_tab_under_its_v1_id(): void { @@ -71,7 +53,7 @@ public function test_the_section_sits_on_the_advanced_tab_under_its_v1_id(): voi } public function test_the_section_shows_v1s_groups_with_their_defaults(): void { - foreach ( self::KEYS as $key ) { + foreach ( self::OPTIONS as $key ) { delete_option( $key ); } @@ -148,7 +130,6 @@ public function test_a_groups_fields_pass_its_v1_filter_under_v1s_ids_and_keep_v $v1_hook = "locked_payment_methods_for_woocommerce/{$group}_settings/get_group_fields"; $v1_extra = static function ( array $fields ) use ( &$seen ): array { $seen[] = \array_keys( $fields ); - return $fields + array( 'extra' => array( 'type' => 'text', @@ -159,7 +140,7 @@ public function test_a_groups_fields_pass_its_v1_filter_under_v1s_ids_and_keep_v add_filter( $v1_hook, $v1_extra ); try { - $ids = \array_column( ( new Settings() )->get_section_fields( 'advanced', self::SECTION ), 'id' ); + $ids = \array_column( new Settings()->get_section_fields( 'advanced', self::SECTION ), 'id' ); } finally { remove_filter( $v1_hook, $v1_extra ); } @@ -178,7 +159,7 @@ public function test_the_plugin_row_links_to_the_section_and_to_the_support_foru $links = apply_filters( "plugin_action_links_{$basename}", array( 'deactivate' => 'Deactivate' ), $basename, array(), 'all' ); $meta = apply_filters( 'plugin_row_meta', array(), $basename, array(), 'all' ); - self::assertStringContainsString( \esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=' . self::SECTION ) ), (string) \reset( $links ) ); + self::assertStringContainsString( esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=' . self::SECTION ) ), (string) \reset( $links ) ); self::assertStringContainsString( 'https://wordpress.org/support/plugin/locked-payment-methods-for-woocommerce/', \implode( '', $meta ) ); self::assertSame( array(), apply_filters( 'plugin_row_meta', array(), 'another-plugin/another-plugin.php', array(), 'all' ) ); } @@ -195,7 +176,6 @@ protected static function selected( \Dom\HTMLDocument $form, string $key ): arra protected function render(): \Dom\HTMLDocument { \ob_start(); \WC_Admin_Settings::output_fields( self::$advanced->get_settings_for_section( self::SECTION ) ); - return \Dom\HTMLDocument::createFromString( '' . \ob_get_clean() . '', \LIBXML_NOERROR ); } diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UninstallTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UninstallTest.php index 8815cf8..c713ff4 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UninstallTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UninstallTest.php @@ -26,7 +26,7 @@ public function test_an_uninstall_checks_the_floors_of_its_own_main_file(): void $source = (string) \file_get_contents( DWS_LPMWC_FILE ); \file_put_contents( DWS_LPMWC_FILE, (string) \preg_replace( '/^(\s*\*\s*Requires PHP:\s*).*$/m', '${1}99.0', $source ) ); $log = (string) \tempnam( \sys_get_temp_dir(), 'dws-tpl-uninstall-' ); - \ini_set( 'error_log', $log ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- The child process's stderr would fail the test. + \ini_set( 'error_log', $log ); try { include \dirname( DWS_LPMWC_FILE ) . '/uninstall.php'; @@ -95,6 +95,35 @@ public function test_an_uninstall_in_a_request_that_never_loaded_the_plugin_remo } } + #[RunInSeparateProcess] + public function test_a_web_uninstall_whose_engine_fails_ends_with_its_message_and_keeps_the_consent(): void { + global $wpdb; + + \define( 'WP_UNINSTALL_PLUGIN', plugin_basename( DWS_LPMWC_FILE ) ); + update_option( DWS_LPMWC_OPTION_DELETE_DATA, 'yes' ); + $failing = "DELETE FROM `$wpdb->options` WHERE `option_name` = '" . Plugin::PREFIX . "_revision'"; + add_filter( 'query', static fn ( string $query ): string => \str_contains( $query, $failing ) ? 'SELECT * FROM dws_lpmwc_missing_table' : $query ); + add_filter( 'wp_die_handler', static fn (): \Closure => static fn ( string $message, string $title, array $args ): never => throw new \LogicException( $args['response'] . ' ' . $message ) ); + $wpdb->suppress_errors(); + + try { + include \dirname( DWS_LPMWC_FILE ) . '/uninstall.php'; + self::fail( 'The uninstall finished.' ); + } catch ( \LogicException $died ) { + self::assertStringStartsWith( '500 Uninstall could not remove everything: option ' . Plugin::PREFIX . '_revision', $died->getMessage() ); + self::assertSame( 'yes', get_option( DWS_LPMWC_OPTION_DELETE_DATA ) ); + } finally { + remove_all_filters( 'query' ); + delete_option( DWS_LPMWC_OPTION_DELETE_DATA ); + delete_option( Plugin::PREFIX . '_revision' ); + wp_cache_flush(); + wp_roles()->for_site(); + // The other tests share the database, so the runner stores again what a new site starts with, and the grants act again. + dws_lpmwc_plugin()->migrations?->run(); + do_action( 'wp_loaded' ); + } + } + #[RunInSeparateProcess] public function test_a_consenting_uninstall_removes_the_grants_v1s_rows_and_the_capabilities(): void { global $wpdb; diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/GroupsStrategyTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/GroupsStrategyTest.php index 025a00a..53f9751 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/GroupsStrategyTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/GroupsStrategyTest.php @@ -4,17 +4,20 @@ use DeepWebSolutions\LockedPaymentMethods\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; use DeepWebSolutions\LockedPaymentMethods\Scoped\Psr\Log\NullLogger as PsrNullLogger; +use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RestoresOptionsTrait; use DeepWebSolutions\LockedPaymentMethods\UnlockStrategies\GroupsStrategy; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( GroupsStrategy::class )] #[Group( 'groups' )] final class GroupsStrategyTest extends TestCase { - protected const array KEYS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_UNLOCK_VIA_GROUPS, DWS_LPMWC_OPTION_GROUPS_UNLOCKED_METHODS ); + use RestoresOptionsTrait; + + protected const array OPTIONS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_UNLOCK_VIA_GROUPS, DWS_LPMWC_OPTION_GROUPS_UNLOCKED_METHODS ); protected static GroupsStrategy $strategy; @@ -22,8 +25,6 @@ final class GroupsStrategyTest extends TestCase { protected static array $users = array(); - protected array $stored = array(); - protected array $groups = array(); // Groups is off by default, so the booted plugin runs no Groups strategy; the class hooks one of its own, as the plugin does with the switch on. @@ -50,26 +51,20 @@ public static function tearDownAfterClass(): void { foreach ( self::$users as $user ) { wp_delete_user( $user ); } - $GLOBALS['wp_filter'] = self::$hooks; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Unhooks the class's strategy and its form. + $GLOBALS['wp_filter'] = self::$hooks; } #[Before] - protected function store_rows(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } + protected function lock_the_methods(): void { update_option( DWS_LPMWC_OPTION_LOCKED_METHODS, array( 'cod', 'bacs' ) ); delete_option( DWS_LPMWC_OPTION_GROUPS_UNLOCKED_METHODS ); } #[After] - protected function restore_rows(): void { + protected function clean_up(): void { foreach ( $this->groups as $group ) { \Groups_Group::delete( $group ); } - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } unset( $_POST['dws_lpmwc_group'] ); wp_set_current_user( 0 ); } @@ -85,7 +80,7 @@ public function test_the_strategy_loads_only_while_groups_is_active_and_its_swit } public function test_the_settings_show_the_groups_switch_while_groups_is_active(): void { - $fields = ( new \DeepWebSolutions\LockedPaymentMethods\Settings() )->get_section_fields( 'advanced', 'dws-locked-payment-methods' ); + $fields = new \DeepWebSolutions\LockedPaymentMethods\Settings()->get_section_fields( 'advanced', 'dws-locked-payment-methods' ); $switch = \array_values( \array_filter( $fields, static fn ( array $field ): bool => DWS_LPMWC_OPTION_UNLOCK_VIA_GROUPS === ( $field['id'] ?? null ) ) ); self::assertSame( 'checkbox', $switch[0]['type'] ?? null ); @@ -212,7 +207,6 @@ protected function create_group( string $name, int $parent_id = 0 ): int { ) ); $this->groups[] = $group; - return $group; } diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/OrderStrategyTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/OrderStrategyTest.php index 59b317f..7c5b4cb 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/OrderStrategyTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/OrderStrategyTest.php @@ -5,23 +5,25 @@ use DeepWebSolutions\LockedPaymentMethods\Plugin; use DeepWebSolutions\LockedPaymentMethods\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; use DeepWebSolutions\LockedPaymentMethods\Scoped\Psr\Log\NullLogger as PsrNullLogger; -use DeepWebSolutions\LockedPaymentMethods\Tests\Support\OrderStorage; +use DeepWebSolutions\LockedPaymentMethods\Tests\Support\OrderStorageTrait; +use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RestoresOptionsTrait; use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RunsRequestsTrait; use DeepWebSolutions\LockedPaymentMethods\UnlockStrategies\OrderStrategy; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( OrderStrategy::class )] final class OrderStrategyTest extends TestCase { - use OrderStorage; + use OrderStorageTrait; + use RestoresOptionsTrait; use RunsRequestsTrait; protected const string FORM = 'dws_lpmwc_order'; - protected const array KEYS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_UNLOCK_VIA_ORDERS ); + protected const array OPTIONS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_UNLOCK_VIA_ORDERS ); protected static OrderStrategy $strategy; @@ -29,8 +31,6 @@ final class OrderStrategyTest extends TestCase { protected static int $admin; - protected array $stored = array(); - // Orders are off by default, so the booted plugin runs no order strategy; the class hooks one of its own, as the plugin does with the switch on. #[\Override] public static function setUpBeforeClass(): void { @@ -51,22 +51,16 @@ public static function setUpBeforeClass(): void { #[\Override] public static function tearDownAfterClass(): void { wp_delete_user( self::$admin ); - $GLOBALS['wp_filter'] = self::$hooks; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Unhooks the class's strategy and its form. + $GLOBALS['wp_filter'] = self::$hooks; } #[Before] - protected function store_rows(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } + protected function lock_the_methods(): void { update_option( DWS_LPMWC_OPTION_LOCKED_METHODS, array( 'cod', 'bacs' ) ); } #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } + protected function clean_up(): void { unset( $_POST[ self::FORM ], $_POST[ self::FORM . '_nonce' ] ); wp_set_current_user( 0 ); } diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/UserProfileStrategyTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/UserProfileStrategyTest.php index df0ad8d..23d8faf 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/UserProfileStrategyTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/UserProfileStrategyTest.php @@ -3,22 +3,23 @@ namespace DeepWebSolutions\LockedPaymentMethods\Tests\Integration\UnlockStrategies; use DeepWebSolutions\LockedPaymentMethods\Plugin; +use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RestoresOptionsTrait; use DeepWebSolutions\LockedPaymentMethods\UnlockStrategies\UserProfileStrategy; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( UserProfileStrategy::class )] final class UserProfileStrategyTest extends TestCase { + use RestoresOptionsTrait; + protected const string FORM = 'dws_lpmwc_user_profile'; - protected const array KEYS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_UNLOCK_VIA_USER_PROFILES, DWS_LPMWC_OPTION_FULL_ACCESS_ROLES ); + protected const array OPTIONS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_UNLOCK_VIA_USER_PROFILES, DWS_LPMWC_OPTION_FULL_ACCESS_ROLES ); protected static array $users = array(); - protected array $stored = array(); - #[\Override] public static function setUpBeforeClass(): void { require_once ABSPATH . 'wp-admin/includes/user.php'; @@ -40,20 +41,14 @@ public static function tearDownAfterClass(): void { // The form evaluates its definition and saves an object once per request, so every row edits a customer of its own. #[Before] - protected function store_rows(): void { + protected function create_the_customer(): void { self::$users['customer'] = self::create_user( 'customer' ); - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } update_option( DWS_LPMWC_OPTION_LOCKED_METHODS, array( 'cod', 'bacs' ) ); update_option( DWS_LPMWC_OPTION_FULL_ACCESS_ROLES, array( 'shop_manager' ) ); } #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } + protected function clean_up(): void { wp_delete_user( self::$users['customer'] ); foreach ( self::$users as $user ) { delete_user_meta( $user, self::key() ); @@ -253,7 +248,6 @@ public function test_the_users_screen_filter_leaves_other_user_queries_alone(): protected static function create_user( string $role ): int { $login = \uniqid( "lpm-profiles-$role-" ); - return (int) wp_insert_user( array( 'user_login' => $login, @@ -266,7 +260,6 @@ protected static function create_user( string $role ): int { protected static function key(): string { global $wpdb; - return $wpdb->get_blog_prefix() . DWS_LPMWC_USER_OPTION_UNLOCKED_METHODS; } @@ -277,7 +270,6 @@ protected static function should_lock( string $gateway_id, int $user_id ): mixed protected static function render( int $user_id ): \Dom\HTMLDocument { \ob_start(); do_action( 'edit_user_profile', get_userdata( $user_id ) ); - return \Dom\HTMLDocument::createFromString( '' . \ob_get_clean() . '', \LIBXML_NOERROR ); } @@ -302,7 +294,7 @@ protected static function listed_users(): array { } protected static function queried_users( ?array $args = null ): array { - return \array_map( 'intval', ( new \WP_User_Query( $args ?? self::query_args() ) )->get_results() ); + return \array_map( 'intval', new \WP_User_Query( $args ?? self::query_args() )->get_results() ); } protected static function query_args(): array { diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/UserRoleStrategyTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/UserRoleStrategyTest.php index f4c9d72..4188367 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/UserRoleStrategyTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/UserRoleStrategyTest.php @@ -3,24 +3,25 @@ namespace DeepWebSolutions\LockedPaymentMethods\Tests\Integration\UnlockStrategies; use DeepWebSolutions\LockedPaymentMethods\Plugin; +use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RecordsDeprecationsTrait; +use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RestoresOptionsTrait; use DeepWebSolutions\LockedPaymentMethods\UnlockStrategies\UserRoleStrategy; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -#[CoversNothing] +#[CoversClass( UserRoleStrategy::class )] final class UserRoleStrategyTest extends TestCase { + use RecordsDeprecationsTrait; + use RestoresOptionsTrait; + protected const string V1_HOOK = 'locked_payment_methods_for_woocommerce/general_settings/get_validated_option_value'; - protected const array KEYS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_UNLOCK_VIA_USER_ROLES, DWS_LPMWC_OPTION_FULL_ACCESS_ROLES ); + protected const array OPTIONS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_UNLOCK_VIA_USER_ROLES, DWS_LPMWC_OPTION_FULL_ACCESS_ROLES ); protected static array $users = array(); - protected array $stored = array(); - - protected array $deprecated = array(); - #[\Override] public static function setUpBeforeClass(): void { require_once ABSPATH . 'wp-admin/includes/user.php'; @@ -44,29 +45,15 @@ public static function tearDownAfterClass(): void { } #[Before] - protected function store_rows(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } + protected function grant_shop_managers_every_method(): void { update_option( DWS_LPMWC_OPTION_FULL_ACCESS_ROLES, array( 'shop_manager' ) ); - add_filter( 'deprecated_hook_trigger_error', '__return_false' ); - add_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ), 10, 2 ); } #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); - remove_action( 'deprecated_hook_run', array( $this, 'record_deprecated_hook' ) ); + protected function log_out(): void { wp_set_current_user( 0 ); } - public function record_deprecated_hook( string $hook, string $replacement ): void { - $this->deprecated[] = array( $hook, $replacement ); - } - public function test_the_strategy_loads_while_its_switch_is_on_as_v1s_did_by_default(): void { delete_option( DWS_LPMWC_OPTION_UNLOCK_VIA_USER_ROLES ); self::assertTrue( UserRoleStrategy::should_load() ); @@ -95,7 +82,7 @@ public function test_only_a_user_with_a_full_access_role_is_unlocked(): void { public function test_an_empty_roles_list_unlocks_no_one(): void { update_option( DWS_LPMWC_OPTION_FULL_ACCESS_ROLES, array() ); - self::assertTrue( ( new UserRoleStrategy() )->unlock_for_full_access_roles( true, 'cod', self::$users['shop_manager'] ) ); + self::assertTrue( new UserRoleStrategy()->unlock_for_full_access_roles( true, 'cod', self::$users['shop_manager'] ) ); } public function test_at_checkout_the_lock_opens_for_a_full_access_user_only(): void { @@ -112,13 +99,12 @@ public function test_v1s_value_filter_on_the_roles_fires_as_deprecated_and_count $seen = array(); $replace = static function ( mixed $value, string $field_id ) use ( &$seen ): array { $seen[] = array( $value, $field_id ); - return array( 'customer' ); }; add_filter( self::V1_HOOK, $replace, 10, 2 ); try { - $lock = ( new UserRoleStrategy() )->unlock_for_full_access_roles( true, 'cod', self::$users['customer'] ); + $lock = new UserRoleStrategy()->unlock_for_full_access_roles( true, 'cod', self::$users['customer'] ); } finally { remove_filter( self::V1_HOOK, $replace ); } diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/WooCommerceMembershipsStrategyTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/WooCommerceMembershipsStrategyTest.php index a9c3ed1..0bc42f4 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/WooCommerceMembershipsStrategyTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/UnlockStrategies/WooCommerceMembershipsStrategyTest.php @@ -5,22 +5,25 @@ use DeepWebSolutions\LockedPaymentMethods\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; use DeepWebSolutions\LockedPaymentMethods\Scoped\Psr\Log\NullLogger as PsrNullLogger; use DeepWebSolutions\LockedPaymentMethods\Tests\Support\MembershipsDouble; +use DeepWebSolutions\LockedPaymentMethods\Tests\Support\RestoresOptionsTrait; use DeepWebSolutions\LockedPaymentMethods\UnlockStrategies\WooCommerceMembershipsStrategy; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; require_once \dirname( __DIR__, 2 ) . '/Support/memberships-functions.php'; // Memberships is a paid plugin absent from WP Packages, so these rows run against a double of its one function and its three hooks, unverified against a licensed copy. -#[CoversNothing] +#[CoversClass( WooCommerceMembershipsStrategy::class )] #[Group( 'wc-memberships' )] final class WooCommerceMembershipsStrategyTest extends TestCase { + use RestoresOptionsTrait; + protected const string FORM = 'dws_lpmwc_membership_plan'; - protected const array KEYS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_UNLOCK_VIA_WC_MEMBERSHIPS, 'wc_memberships_version' ); + protected const array OPTIONS = array( DWS_LPMWC_OPTION_LOCKED_METHODS, DWS_LPMWC_OPTION_UNLOCK_VIA_WC_MEMBERSHIPS, 'wc_memberships_version' ); protected static WooCommerceMembershipsStrategy $strategy; @@ -28,8 +31,6 @@ final class WooCommerceMembershipsStrategyTest extends TestCase { protected static array $users = array(); - protected array $stored = array(); - protected int $plan; // Memberships is off by default, so the booted plugin runs no Memberships strategy; the class hooks one of its own, as the plugin does with the switch on. @@ -58,15 +59,12 @@ public static function tearDownAfterClass(): void { wp_delete_user( $user ); } unregister_post_type( 'wc_membership_plan' ); - $GLOBALS['wp_filter'] = self::$hooks; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Unhooks the class's strategy and its form. + $GLOBALS['wp_filter'] = self::$hooks; } // The form evaluates its definition and saves an object once per request, so every row edits a plan of its own. #[Before] - protected function store_rows(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } + protected function create_the_plan(): void { update_option( DWS_LPMWC_OPTION_LOCKED_METHODS, array( 'cod', 'bacs' ) ); update_option( 'wc_memberships_version', '1.27.0' ); $this->plan = (int) wp_insert_post( @@ -79,11 +77,8 @@ protected function store_rows(): void { } #[After] - protected function restore_rows(): void { + protected function clean_up(): void { wp_delete_post( $this->plan, true ); - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } MembershipsDouble::$memberships = array(); unset( $_POST[ self::FORM ], $_POST[ self::FORM . '_nonce' ], $GLOBALS['post'] ); wp_set_current_user( 0 ); diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/bootstrap.php b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/bootstrap.php index fe4c33b..0d92d74 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Integration/bootstrap.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Integration/bootstrap.php @@ -1,5 +1,21 @@ */ -function wc_memberships_get_user_active_memberships( $user_id = null, $args = array() ) {} // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- A declaration only. +function wc_memberships_get_user_active_memberships( $user_id = null, $args = array() ) {} diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/README.md b/plugins/locked-payment-methods-for-woocommerce/tests/README.md index ab7a784..5189667 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/README.md +++ b/plugins/locked-payment-methods-for-woocommerce/tests/README.md @@ -6,8 +6,8 @@ This document describes the plugin's three test suites, the environments they ru | Suite | Where | Runs on | Covers | | --- | --- | --- | --- | -| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree, the footprint's shape and coverage of v1's data, and the readme's version and floors | -| Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce, Groups and order tables loaded | the boot, the lock and its relaxation, fresh requests below the PHP and WooCommerce floors, every strategy and its form, the settings, the capabilities, v1's deprecated functions and hooks, the migrations from v1, deactivation, uninstall, the peer getters, and a partial deploy: a missing build file or engine, or an include that throws; every row whose outcome the order storage can change runs on all four storages through `OrderStorage`, the Groups rows run in the `groups` group against Groups itself, and the Memberships rows in the `wc-memberships` group against a double of its one function | +| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree, the footprint's shape and coverage of v1's data, the readme's version and floors, the entry's `WC tested up to` against the locked WooCommerce, the entry's lack of a `Tested up to`, which the readme alone holds, and the stub in every shipped directory | +| Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce, Groups and order tables loaded, and the multisite site, without Groups, for its `multisite` group | the boot, the lock and its relaxation, fresh requests below the PHP and WooCommerce floors, every strategy and its form, the settings, the capabilities, v1's deprecated functions and hooks, the migrations from v1, deactivation, uninstall, the peer getters, and a partial deploy: a missing build file or engine, or an include that throws, and the boot and settings on a second site of a network; every row whose outcome the order storage can change runs on all four storages through `OrderStorageTrait`, the Groups rows run in the `groups` group against Groups itself, and the Memberships rows in the `wc-memberships` group against a double of its one function | | End to end | `tests/EndToEnd/` | the tests site, in a browser through Playwright | a locked payment method a customer is granted on the block checkout, the Store API's refusal of a guest's checkout with a locked payment method, and the plugin with WooCommerce deactivated; the monorepo's shared environment checks a locked payment method's absence from a guest's classic and block checkout | PHPUnit runs both PHP suites in random order, so each row sets up what it needs and restores what it changes; its cache lives in `tests/.cache/phpunit/`, and Playwright's output in `tests/.cache/artifacts/`. @@ -16,8 +16,9 @@ PHPUnit runs both PHP suites in random order, so each row sets up what it needs - `.wp-env.json` is the development site on , as `local`, where the framework rethrows boot and migration failures. - `.wp-env.tests.json` is the tests site on , as `production`, which the Integration and end-to-end suites share. A row whose separate process changes the database flushes the parent's object cache, and the end-to-end specs set their own preconditions, because either suite may run first. +- `.wp-env.multisite.json` is a network on , as `production`, with WooCommerce and the plugin network-activated, where the Integration suite's `multisite` group runs and the default run skips it. -Both start WordPress on PHP 8.5 with WooCommerce and Groups from `vendor/` and WooCommerce's order tables on. +Each starts WordPress on PHP 8.5 with WooCommerce from `vendor/` and WooCommerce's order tables on, and the development and tests sites load Groups from `vendor/` too. ## Running @@ -33,6 +34,12 @@ Start the tests site and run the Integration suite: composer test:integration ``` +Start the multisite site, build the scripts and run the Integration suite's `multisite` group: + +```sh +composer test:integration:multisite +``` + Run the end-to-end suite, which starts the tests site when it is down and needs, once per machine, Playwright's Chromium: ```sh diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Support/OrderStorage.php b/plugins/locked-payment-methods-for-woocommerce/tests/Support/OrderStorageTrait.php similarity index 85% rename from plugins/locked-payment-methods-for-woocommerce/tests/Support/OrderStorage.php rename to plugins/locked-payment-methods-for-woocommerce/tests/Support/OrderStorageTrait.php index 13eea6b..18e4e0b 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Support/OrderStorage.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Support/OrderStorageTrait.php @@ -4,8 +4,8 @@ use PHPUnit\Framework\Attributes\After; -// WooCommerce reads these options on every order load, so pre_option filters move this process to another storage, with or without the order tables' data caching, without changing the tests site. -trait OrderStorage { +// WooCommerce reads these options on every order load, so pre_option filters move this process to another storage, with the order tables' data cache off, without changing the tests site. +trait OrderStorageTrait { protected array $orders = array(); protected array $storage_filters = array(); @@ -19,11 +19,11 @@ public static function storages(): array { ); } - protected function use_storage( bool $hpos, bool $sync, bool $caching = false ): void { + protected function use_storage( bool $hpos, bool $sync ): void { $this->storage_filters = array( 'pre_option_woocommerce_custom_orders_table_enabled' => static fn (): string => $hpos ? 'yes' : 'no', 'pre_option_woocommerce_custom_orders_table_data_sync_enabled' => static fn (): string => $sync ? 'yes' : 'no', - 'pre_option_woocommerce_hpos_datastore_caching_enabled' => static fn (): string => $caching ? 'yes' : 'no', + 'pre_option_woocommerce_hpos_datastore_caching_enabled' => static fn (): string => 'no', ); foreach ( $this->storage_filters as $hook => $filter ) { add_filter( $hook, $filter ); @@ -34,7 +34,6 @@ protected function create_order( array $args = array() ): \WC_Order { $order = wc_create_order( $args + array( 'status' => 'pending' ) ); self::assertInstanceOf( \WC_Order::class, $order ); $this->orders[] = $order->get_id(); - return $order; } @@ -47,11 +46,13 @@ protected function delete_orders_and_restore_storage(): void { $order->delete( true ); } } + $this->orders = array(); foreach ( $this->storage_filters as $hook => $filter ) { remove_filter( $hook, $filter ); } + $this->storage_filters = array(); } } diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Support/RecordsDeprecationsTrait.php b/plugins/locked-payment-methods-for-woocommerce/tests/Support/RecordsDeprecationsTrait.php new file mode 100644 index 0000000..f85e9f5 --- /dev/null +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Support/RecordsDeprecationsTrait.php @@ -0,0 +1,48 @@ +listened as $hook ) { + remove_filter( $hook, array( $this, 'pass_through' ) ); + } + + remove_action( 'deprecated_hook_run', array( $this, 'record_deprecation' ) ); + remove_action( 'deprecated_function_run', array( $this, 'record_deprecation' ) ); + remove_filter( 'deprecated_hook_trigger_error', '__return_false' ); + remove_filter( 'deprecated_function_trigger_error', '__return_false' ); + } + + public function record_deprecation( string $name, string $replacement ): void { + $this->deprecated[] = array( $name, $replacement ); + } + + // WordPress reports a deprecated hook only when something listens on it. + protected function listen_on( string ...$hooks ): void { + foreach ( $hooks as $hook ) { + add_filter( $hook, array( $this, 'pass_through' ) ); + $this->listened[] = $hook; + } + } + + public function pass_through( mixed $value = null ): mixed { + return $value; + } +} diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Support/RestoresOptionsTrait.php b/plugins/locked-payment-methods-for-woocommerce/tests/Support/RestoresOptionsTrait.php new file mode 100644 index 0000000..ee4b174 --- /dev/null +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Support/RestoresOptionsTrait.php @@ -0,0 +1,24 @@ +stored_options[ $key ] = get_option( $key ); + } + } + + #[After] + protected function restore_options(): void { + foreach ( $this->stored_options as $key => $value ) { + false === $value ? delete_option( $key ) : update_option( $key, $value ); + } + } +} diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Support/RunsRequestsTrait.php b/plugins/locked-payment-methods-for-woocommerce/tests/Support/RunsRequestsTrait.php index 2042376..86cd4a5 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Support/RunsRequestsTrait.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Support/RunsRequestsTrait.php @@ -3,13 +3,18 @@ namespace DeepWebSolutions\LockedPaymentMethods\Tests\Support; trait RunsRequestsTrait { - /** - * @return array{status: int, output: string, last: string} - */ - protected static function request( string $php, string $flags = '' ): array { - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . " $flags eval " . \escapeshellarg( $php ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Each row needs a fresh request that loads the plugin. - $output = \array_values( \array_filter( $output, static fn ( string $line ): bool => '' !== \trim( $line ) ) ); + protected static function request( string $php, string ...$flags ): array { + return self::request_provoking( '', $php, ...$flags ); + } + + // A row whose fixture provokes a diagnostic names it, so that diagnostic and what follows it, such as a fatal error's trace, pass, while any output before it fails the row. + protected static function request_provoking( string $diagnostic, string $php, string ...$flags ): array { + $arguments = \implode( '', \array_map( static fn ( string $flag ): string => ' ' . \escapeshellarg( $flag ), $flags ) ); + \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . "$arguments eval " . \escapeshellarg( $php ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Each row needs a fresh request that loads the plugin. + $output = \array_values( \array_filter( $output, static fn ( string $line ): bool => '' !== \trim( $line ) ) ); + $provoked = '' === $diagnostic ? null : \array_find_key( $output, static fn ( string $line ): bool => \str_contains( $line, $diagnostic ) ); + self::assertSame( array(), \array_slice( $output, 0, \is_int( $provoked ) ? $provoked : \max( 0, \count( $output ) - 1 ) ), \implode( "\n", $output ) ); return array( 'status' => $status, 'output' => \implode( "\n", $output ), diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Support/memberships-functions.php b/plugins/locked-payment-methods-for-woocommerce/tests/Support/memberships-functions.php index 806a477..074d787 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Support/memberships-functions.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Support/memberships-functions.php @@ -8,7 +8,7 @@ use DeepWebSolutions\LockedPaymentMethods\Tests\Support\MembershipsDouble; if ( ! function_exists( 'wc_memberships_get_user_active_memberships' ) ) { - function wc_memberships_get_user_active_memberships( $user_id = null, $args = array() ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Memberships' signature. + function wc_memberships_get_user_active_memberships( $user_id = null, $args = array() ) { return MembershipsDouble::$memberships[ (int) $user_id ] ?? array(); } } diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Unit/DirectoryStubsTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Unit/DirectoryStubsTest.php new file mode 100644 index 0000000..0dd983d --- /dev/null +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Unit/DirectoryStubsTest.php @@ -0,0 +1,33 @@ + $file->isDir() && ! \str_starts_with( $file->getFilename(), '.' ) && ! \in_array( $file->getFilename(), self::SKIPPED, true ) + ), + \RecursiveIteratorIterator::SELF_FIRST + ); + $missing = array(); + foreach ( array( $root, ...\array_keys( \iterator_to_array( $directories ) ) ) as $directory ) { + $stub = "$directory/index.php"; + if ( ! \is_file( $stub ) || " 'wp-plugin/woocommerce' === $package['name'] ); + $entry = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/' . Plugin::SLUG . '.php' ); + + self::assertIsArray( $woocommerce ); + self::assertSame( 1, \preg_match( '/^\s*\*\s*WC tested up to:\s*(\S+)\s*$/m', $entry, $header ) ); + self::assertSame( \implode( '.', \array_slice( \explode( '.', $woocommerce['version'] ), 0, 2 ) ), $header[1] ); + } +} diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Unit/FootprintTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Unit/FootprintTest.php index e9e4d0a..fdb780c 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Unit/FootprintTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Unit/FootprintTest.php @@ -17,7 +17,7 @@ public function test_the_prefix_has_one_segment_after_dws_so_no_prefix_in_its_fa } public function test_every_kind_is_one_the_uninstall_engine_removes(): void { - $doc = (string) ( new \ReflectionClass( Uninstaller::class ) )->getDocComment(); + $doc = (string) new \ReflectionClass( Uninstaller::class )->getDocComment(); self::assertSame( 1, \preg_match( '/@phpstan-type Footprint (.+)$/m', $doc, $type ) ); \preg_match_all( '/(\w+)\?:/', $type[1], $kinds ); diff --git a/plugins/locked-payment-methods-for-woocommerce/tests/Unit/ReadmeTest.php b/plugins/locked-payment-methods-for-woocommerce/tests/Unit/ReadmeTest.php index a17ccb3..1ac0674 100644 --- a/plugins/locked-payment-methods-for-woocommerce/tests/Unit/ReadmeTest.php +++ b/plugins/locked-payment-methods-for-woocommerce/tests/Unit/ReadmeTest.php @@ -16,6 +16,12 @@ public function test_the_readme_repeats_the_entry_files_version_and_floors(): vo self::assertSame( $entry['Requires PHP'], $readme['Requires PHP'] ); } + // Plugin Check refuses a Tested up to in the entry, in any case, as WordPress reads headers, so the readme holds the only one. + public function test_the_entry_leaves_tested_up_to_to_the_readme(): void { + self::assertDoesNotMatchRegularExpression( '/^(?:[ \t]*<\?(?:php)?)?[ \t\/*#@]*Tested up to:/mi', (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/locked-payment-methods-for-woocommerce.php' ) ); + self::assertArrayHasKey( 'Tested up to', self::read_fields( 'readme.txt' ) ); + } + public function test_the_readme_holds_the_changelog_markers_the_sync_fills(): void { $readme = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/readme.txt' ); @@ -24,7 +30,6 @@ public function test_the_readme_holds_the_changelog_markers_the_sync_fills(): vo protected static function read_fields( string $file ): array { \preg_match_all( '/^[\s*]*([A-Za-z ]+):\s*(\S.*?)\s*$/m', (string) \file_get_contents( \dirname( __DIR__, 2 ) . "/$file" ), $matches ); - return \array_combine( $matches[1], $matches[2] ); } } diff --git a/plugins/locked-payment-methods-for-woocommerce/uninstall.php b/plugins/locked-payment-methods-for-woocommerce/uninstall.php index 2f184b5..bd2d658 100644 --- a/plugins/locked-payment-methods-for-woocommerce/uninstall.php +++ b/plugins/locked-payment-methods-for-woocommerce/uninstall.php @@ -32,7 +32,6 @@ require_once __DIR__ . '/includes/grants.php'; require_once __DIR__ . '/includes/options.php'; $dws_lpmwc_footprint = dws_lpmwc_get_footprint(); - $dws_lpmwc_consent = DWS_LPMWC_OPTION_DELETE_DATA; $dws_lpmwc_meta_types = MetaTypes::DEFINITIONS; if ( ! class_exists( Uninstaller::class ) ) { throw new RuntimeException( 'its uninstall engine is missing.' ); @@ -47,11 +46,20 @@ return; } -Uninstaller::run( - $dws_lpmwc_file, - $dws_lpmwc_footprint, - array( - 'consent' => array( 'option' => $dws_lpmwc_consent ), - 'meta_types' => $dws_lpmwc_meta_types, - ) -); +try { + Uninstaller::run( + $dws_lpmwc_file, + $dws_lpmwc_footprint, + array( + 'consent' => array( 'option' => DWS_LPMWC_OPTION_DELETE_DATA ), + 'meta_types' => $dws_lpmwc_meta_types, + ) + ); +} catch ( RuntimeException $exception ) { + if ( defined( 'WP_CLI' ) && true === WP_CLI ) { + throw $exception; + } + + // wp_die() ends the request before WordPress deletes the files, so the next uninstall retries, and shows the reason where a throw shows only the critical-error page. + wp_die( esc_html( $exception->getMessage() ), '', array( 'response' => 500 ) ); +}