diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 65dc1261..fb977a4d 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -18,4 +18,4 @@ jobs: audit: name: Audit # npm's dev-dependency advisories surface through Dependabot alerts instead of failing this gate. - uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@b107001cce9f2960495d9d50481c8540d228f60c diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 291b06e8..1e96c4e0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,4 +21,4 @@ jobs: actions: read contents: read security-events: write - uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@b107001cce9f2960495d9d50481c8540d228f60c diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 5d3394df..e2f48822 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -16,7 +16,7 @@ jobs: name: Lint PHP permissions: contents: read - uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@b107001cce9f2960495d9d50481c8540d228f60c with: scripts: '["lint:php:phpcs", "lint:php:phpcs:tests", "lint:php:phpstan", "changelog:validate"]' @@ -24,7 +24,7 @@ jobs: name: Lint scripts permissions: contents: read - uses: ahegyes/wordpress-configs/.github/workflows/reusable-scripts-styles-lint.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-scripts-styles-lint.yml@b107001cce9f2960495d9d50481c8540d228f60c with: scripts: '["lint:scripts"]' @@ -32,7 +32,7 @@ jobs: name: PHP 7.4 syntax (bootstrap) permissions: contents: read - uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@b107001cce9f2960495d9d50481c8540d228f60c with: php-versions: '["7.4"]' paths: '["packages/bootstrap"]' @@ -76,7 +76,7 @@ jobs: needs: consumer-a-build permissions: contents: read - uses: ahegyes/wordpress-configs/.github/workflows/reusable-plugin-check.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-plugin-check.yml@b107001cce9f2960495d9d50481c8540d228f60c with: artifact: consumer-a plugin-slug: acme-consumer-a @@ -88,7 +88,7 @@ jobs: needs: consumer-a-build permissions: contents: read - uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@b107001cce9f2960495d9d50481c8540d228f60c with: artifact: consumer-a php-versions: '["7.4"]' diff --git a/.github/workflows/tests-nightly.yml b/.github/workflows/tests-nightly.yml index 409b5419..e0f69ecb 100644 --- a/.github/workflows/tests-nightly.yml +++ b/.github/workflows/tests-nightly.yml @@ -12,7 +12,7 @@ permissions: jobs: integration: name: Integration - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd + 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' @@ -21,7 +21,7 @@ jobs: integration-multisite: name: Integration (multisite) - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd + 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' @@ -30,7 +30,7 @@ jobs: integration-woocommerce-nightly: name: Integration (WooCommerce nightly) - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c with: php-version: '8.5' composer-script: 'test:integration:woocommerce-nightly:run' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ab9f25be..e1fb42f0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,7 @@ permissions: jobs: unit: name: Unit - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c with: php-version: '8.5' composer-script: 'test:unit' @@ -51,7 +51,7 @@ jobs: fail-fast: false matrix: wp: ${{ fromJSON(needs.wordpress.outputs.versions) }} - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c with: php-version: '8.5' wp-version: ${{ matrix.wp }} @@ -65,7 +65,7 @@ jobs: fail-fast: false matrix: wp: ${{ fromJSON(needs.wordpress.outputs.versions) }} - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c with: php-version: '8.5' wp-version: ${{ matrix.wp }} @@ -79,7 +79,7 @@ jobs: fail-fast: false matrix: wp: ${{ fromJSON(needs.wordpress.outputs.versions) }} - uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@b107001cce9f2960495d9d50481c8540d228f60c with: artifact-slug: wp-${{ matrix.wp }} php-version: '8.5' @@ -89,7 +89,7 @@ jobs: integration-php74: name: Integration (PHP 7.4) - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c with: php-version: '8.5' composer-script: 'test:integration:php74:run' @@ -97,7 +97,7 @@ jobs: prefer-lowest: name: Prefer lowest - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c with: php-version: '8.5' composer-script: 'test:integration:lowest:run' diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml index d658c402..e954a682 100644 --- a/.github/workflows/workflow-checks.yml +++ b/.github/workflows/workflow-checks.yml @@ -24,4 +24,4 @@ jobs: contents: read security-events: write actions: read - uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@c944e73ab2656633397e7736ea104bbe72726ffd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@b107001cce9f2960495d9d50481c8540d228f60c diff --git a/AGENTS.md b/AGENTS.md index b88efd81..68b7fca6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,10 +21,10 @@ wordpress-framework/ │ ├── Migration/{MigrationRunner, BatchedMigrationStepInterface} │ ├── Permission/CapabilityGrants │ ├── Scheduling/{SchedulerInterface, Scheduler, ScheduledHook, Recurrence, Backends/ (@internal)} -│ ├── Storage/{OptionRows, OptionRow, Lock, LockClaim} +│ ├── Storage/{OptionRows, OptionRow, Lock, LockClaim, StoredData} │ └── Uninstall/Uninstaller ├── packages/settings/ ahegyes/wp-framework-settings `require.php` · core ^2.0 -│ └── src/ psr-4 DeepWebSolutions\Framework\Settings\ · Forms (render_control, sanitize_value, sanitize_fields, is_disabled, is_group, get_title, get_rejection_message, check_helper_id, render_hidden_input, escape_attribute, get_rows (@internal)) +│ └── src/ psr-4 DeepWebSolutions\Framework\Settings\ · Forms (render_control, sanitize_fields, is_disabled, is_group, get_title, get_rejection_message, check_helper_id, render_hidden_input, escape_attribute, get_rows (@internal)) │ ├── AdminPage/{OptionsPage, OptionsPageDefinitionInterface} · pages store each tab's fields in one option through the Settings API │ └── EditScreen/{ObjectForm, ObjectFormDefinitionInterface, ObjectFormHostInterface, ObjectStoreInterface, FormTable (@internal), Hosts/{PostMetaBox, UserProfile, TermForm}, Stores/MetadataStore} · forms store each field on the edited object through a store ├── packages/woocommerce/ ahegyes/wp-framework-woocommerce `require.php` · settings ^2.0 · WooCommerce as host, at the plugin's `WC requires at least` @@ -88,9 +88,9 @@ actionlint && zizmor .github/ ## Contract -The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too. +The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too. -A package README may name namespaces whose symbols are internal, and a class there is internal even without its tag; core's is `Core\Scheduling\Backends\`. +A package README may name namespaces whose symbols are internal, and a class there is internal even without its tag; core's is `Core\Scheduling\Backends\`. `SettingsSection` follows WooCommerce's experimental settings API, so the methods it inherits, `get_settings_ui_page()`, `get_script_handles()` and `get_save_adapter()`, follow WooCommerce and may change in a minor release. Releases follow SemVer on this contract from the first tag: a break of it ships as the next major, and internals change in any release. @@ -112,12 +112,12 @@ Releases follow SemVer on this contract from the first tag: a break of it ships ## Limitations 1. A throw in `register_hooks()` leaves earlier hooks live, and side effects are never undone. Safeguards still ignore relaxations, because `{prefix}/boot/succeeded` never fires. Gates evaluate once, at `plugins_loaded`. Schema-gated components switch on one request after the migrating request. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`, so its post types and endpoints first register on the next request. -2. A request killed mid-migration holds the lock until 10 minutes after the step or batch it was running began, then the step re-runs and the error row names the step that did not finish. One primary database is assumed. While a plain step's failure persists in production, each request issues one `INSERT IGNORE` plus one `SELECT`, one more `SELECT` until a first step succeeds, and admin screens read the error row; in local and development environments the lock is released, so every request re-runs the failing step. While a batched step is pending, a request outside the runner's hook takes no lock and only looks the hook up in the scheduler. A WP-CLI process is one request, so once it has spent the runner's time budget, a long `wp action-scheduler run` only reschedules the runner's hook each time it comes due, and the next process continues the migration. After a rollback, older code runs on newer data until the plugin is upgraded again; the written-back revision then re-runs the newer steps, and `{option}_high_water` keeps the highest revision reached. Mixed-version rolling deploys may re-run steps, which converge. -3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. An `option_prefixes` or `user_meta_global_prefixes` entry that the table prefix starts with, such as `wp_` on a default install, also deletes the site's roles and every user's capabilities, which WordPress names after the table prefix; WordPress reserves `wp_` for itself. On an installation of several networks, the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a job that cannot be unscheduled is logged and left. +2. A request killed mid-migration holds the lock until 10 minutes after the step or batch it was running began, then the step re-runs and the error row names the step that did not finish. One primary database is assumed. While a plain step's failure persists in production, each request issues one `INSERT IGNORE` plus one `SELECT`, one more `SELECT` until a first step succeeds, and admin screens read the error row; in local and development environments the lock is released, so every request re-runs the failing step. While a batched step is pending, a request outside the runner's hook takes no lock and only looks the hook up in the scheduler, and while its failure persists, each queue run fires the runner's hook to no effect and the failure is logged every 10 minutes. A WP-CLI process is one request, so once it has spent the runner's time budget, a long `wp action-scheduler run` stops running the migration, and the next request or process continues it. After a rollback, older code runs on newer data until the plugin is upgraded again; the written-back revision then re-runs the newer steps, and `{option}_high_water` keeps the highest revision reached. Mixed-version rolling deploys may re-run steps, which converge. +3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. Exact `options`, `transients` and `network_options` names match under the database collation, as WordPress's option API matches them. An `option_prefixes` or `user_meta_global_prefixes` entry that the table prefix starts with, such as `wp_` on a default install, also deletes the site's roles and every user's capabilities, which WordPress names after the table prefix; WordPress reserves `wp_` for itself. On an installation of several networks, the large-network bound counts the current network's sites, and the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a job that cannot be unscheduled is logged and left. 4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared. 5. Partial bootstrap package: if `load.php` exists but a file it requires is missing, the `Error` is thrown before the template's catch and the plugin fatals at include; its uninstall shim catches it, except on PHP 7.4, where a failed `require` is fatal. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin. 6. Deactivation and recovery mode unload safeguards, as for any plugin. Plugins whose data would leak (IC) hide it at the data level. -7. Scheduling and one-shot notices are best effort. Either scheduler can run an invocation twice or not at all, and work advances only with site traffic, Action Scheduler's admin dispatch, or a system cron or WP-CLI runner. On hosts that replace WP-Cron's store, the WP-Cron backend still reads only the `cron` option, so it may not see the host's events. Action Scheduler compares hook names and arguments under the database collation, so identities that differ only in letter case are one there. Action Scheduler stores a string argument holding invalid UTF-8 with those bytes replaced, which its callbacks then receive, while WP-Cron's write of it fails, because WordPress refuses invalid UTF-8 in the options table. Two concurrent renders can both show a one-shot notice, a request that dies after output shows it again, and a block-editor delivery whose response the editor never receives, as when the user leaves during it, loses its one-shots. Block-editor screens hide classic notices: the post editor fetches the site, network and user one-shots that name no screens as it loads and after each save that is not an autosave, while persistent and screen-limited notices wait for a classic screen, as every notice does on the other block editors. A dismissal, or a one-shot's showing, applies to the notice's id rather than to the copy the page showed, so a notice saved again under that id after the page showed it is deleted or hidden too. Two writes to one scope's notices, or to one viewer's dismissals, at the same moment can lose one of them: a notice added then never shows, a dismissal is undone, or a consumed one-shot shows once more. A notice whose write fails is lost, and logged only when the write throws. -8. Capability grants act on `wp_loaded` after the declaration changes or a declared role appears or is deleted. A granted capability removed or denied by hand stays so; a declared role deleted and created again is granted again, unless it was deleted while the plugin was inactive, which then reads as a hand removal. WP_Roles writes the whole roles row from the request's copy, so another request's role write in between is lost. A change the row does not keep is logged and made again on every request, which never ends on a site whose roles are defined in code (`$wp_user_roles`) or locked. -9. `TransientCache` and `ObjectCache` are best effort. On the options table a value holding invalid UTF-8 is refused and `set()` returns false, and a `delete()` whose database write fails still returns true, so later requests read the entry until it expires or is set again. An object whose class no longer loads reads back as `__PHP_Incomplete_Class`, so a plugin clears its caches when cached classes change. `getMultiple()` returns a decimal-string key as an integer, as PHP arrays do. `clear()` leaves earlier entries in place until they expire or the object cache evicts them, and behind a multi-node object cache that is not coherent, a node may read the old generation or salt until the new one reaches it. Without a persistent object cache, `ObjectCache` entries last one request, and an entry outlives its TTL until the request ends; some Memcached drop-ins keep a TTL over 30 days until eviction. Concurrent misses all recompute; nothing locks a key. +7. Scheduling and one-shot notices are best effort. Either scheduler can run an invocation twice or not at all, and work advances only with site traffic, Action Scheduler's admin dispatch, or a system cron or WP-CLI runner. On hosts that replace WP-Cron's store, the WP-Cron backend still reads only the `cron` option, so it may not see the host's events. Action Scheduler compares hook names and arguments under the database collation, so identities that differ only in letter case are one there. Action Scheduler stores a string argument holding invalid UTF-8 with those bytes replaced, which its callbacks then receive, while WP-Cron's write of it fails, because WordPress refuses invalid UTF-8 in the options table. Two concurrent renders can both show a one-shot notice, a request that dies after output shows it again, and a block-editor delivery whose response the editor never receives, as when the user leaves during it, loses its one-shots. Block-editor screens hide classic notices: the post editor fetches the site, network and user one-shots that name no screens as it loads and after each save that is not an autosave, while persistent and screen-limited notices wait for a classic screen, as every notice does on the other block editors. The post editor shows a notice's markup through core/notices' `__unstableHTML` option, which a WordPress release may change or remove. A dismissal, or a one-shot's showing, applies to the notice's id rather than to the copy the page showed, so a notice saved again under that id after the page showed it is deleted or hidden too. Two writes to one scope's notices, or to one viewer's dismissals, at the same moment can lose one of them: a notice added then never shows, a dismissal is undone, or a consumed one-shot shows once more. A notice whose write fails is lost, and logged only when the write throws. +8. Capability grants act on `wp_loaded` after the declaration changes or a declared role appears or is deleted, and the current user sees a grant or revocation from the next request. A granted capability removed or denied by hand stays so; a declared role deleted and created again is granted again, unless it was deleted while the plugin was inactive, which then reads as a hand removal. WP_Roles writes the whole roles row from the request's copy, so another request's role write in between is lost. A change the row does not keep is logged and made again on every request, which never ends on a site whose roles are defined in code (`$wp_user_roles`) or locked. +9. `TransientCache` and `ObjectCache` are best effort. On the options table a value holding invalid UTF-8 is refused and `set()` returns false, and a `delete()` whose database write fails still returns true, so later requests read the entry until it expires or is set again. `getMultiple()` returns a decimal-string key as an integer, as PHP arrays do. `clear()` leaves earlier entries in place until they expire or the object cache evicts them, and behind a multi-node object cache that is not coherent, a node may read the old generation or salt until the new one reaches it. Without a persistent object cache, `ObjectCache` entries last one request, and an entry outlives its TTL until the request ends; some Memcached drop-ins keep a TTL over 30 days until eviction. Concurrent misses all recompute; nothing locks a key. 10. The framework's queries name tables through `%i`, so a `wpdb` replacement whose `prepare()` predates WordPress 6.2 breaks them. A `Lock` lives in the current site's options table, and a hold is judged by the TTL its holder wrote. A holder learns of a takeover only at its next `refresh()`, so data the lock protects is written with a compare-and-swap, such as `OptionRows::replace()`, where a stale write matters. diff --git a/composer.lock b/composer.lock index 1919c8e7..8f22bc70 100644 --- a/composer.lock +++ b/composer.lock @@ -148,7 +148,7 @@ "dist": { "type": "path", "url": "packages/woocommerce", - "reference": "657eb2dbc0484dae31ff8c97c7786716c9090c17" + "reference": "390b60d89ae0ec911501d74ebd77e7255cafc462" }, "require": { "ahegyes/wp-framework-settings": "^2.0", @@ -159,14 +159,14 @@ "branch-alias": { "dev-trunk": "2.x-dev" }, - "scoping-stubs": [ - "php-stubs/woocommerce-stubs" - ], - "scoping-text-domain": "wp-framework", "requires-wp": "7.1", "requires-plugins": [ "woocommerce" - ] + ], + "scoping-stubs": [ + "php-stubs/woocommerce-stubs" + ], + "scoping-text-domain": "wp-framework" }, "autoload": { "psr-4": { @@ -397,12 +397,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wordpress-configs.git", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd" + "reference": "b107001cce9f2960495d9d50481c8540d228f60c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd", + "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c", + "reference": "b107001cce9f2960495d9d50481c8540d228f60c", "shasum": "" }, "require": { @@ -530,7 +530,7 @@ "source": "https://github.com/ahegyes/wordpress-configs/tree/trunk", "issues": "https://github.com/ahegyes/wordpress-configs/issues" }, - "time": "2026-11-07T10:23:14+00:00" + "time": "2026-11-09T15:51:41+00:00" }, { "name": "automattic/jetpack-changelogger", diff --git a/package-lock.json b/package-lock.json index ec29922f..220ebecd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "name": "@ahegyes/wordpress-framework", "license": "GPL-3.0-or-later", "devDependencies": { - "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#c944e73ab2656633397e7736ea104bbe72726ffd", + "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#b107001cce9f2960495d9d50481c8540d228f60c", "@playwright/test": "^1.64.1", "@wordpress/e2e-test-utils-playwright": "^3.1.0", "@wordpress/env": "^12.0.0", @@ -20,7 +20,7 @@ }, "node_modules/@ahegyes/wordpress-configs": { "version": "0.0.0", - "resolved": "git+ssh://git@github.com/ahegyes/wordpress-configs.git#c944e73ab2656633397e7736ea104bbe72726ffd", + "resolved": "git+ssh://git@github.com/ahegyes/wordpress-configs.git#b107001cce9f2960495d9d50481c8540d228f60c", "integrity": "sha512-biLQ7ozE+hfclZpFvoAb7WbWDcmdI1poZJayP5vjQ/d8yYbti3hCEnBKKyUE6Q09hBlH60vDsrS2GsKVELIzOg==", "dev": true, "license": "MIT", diff --git a/package.json b/package.json index 78d2ac2e..2f9cf46f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "npm": ">=11" }, "devDependencies": { - "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#c944e73ab2656633397e7736ea104bbe72726ffd", + "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#b107001cce9f2960495d9d50481c8540d228f60c", "@playwright/test": "^1.64.1", "@wordpress/e2e-test-utils-playwright": "^3.1.0", "@wordpress/env": "^12.0.0", diff --git a/packages/bootstrap/README.md b/packages/bootstrap/README.md index 6de0d067..d43eadc5 100644 --- a/packages/bootstrap/README.md +++ b/packages/bootstrap/README.md @@ -79,13 +79,13 @@ if ( ! Requirements::check_floors( __FILE__ ) ) { require_once __DIR__ . '/functions.php'; ``` -The updater offers the plugin's GitHub releases through WordPress's own update screens while the plugin's `Update URI` header points at its github.com repository. It installs the release asset named after the plugin's text domain, such as `acme-persona-bootstrap.zip`, and the `dws_github_updater/config/{text-domain}` filter changes the repository, the asset or the channel, or turns updates off. It offers only tags such as `v2.1.0` or `v2.1.0-rc.1`, whose optional suffix is `-dev`, `-alpha`, `-beta` or `-rc` in any letter case, optionally numbered, because PHP's `version_compare()` ranks other suffixes above their release. +The updater offers the plugin's GitHub releases through WordPress's own update screens while the plugin's `Update URI` header points at its github.com repository. It installs the release asset named after the plugin's text domain, such as `acme-persona-bootstrap.zip`, and the `dws_github_updater/config/{text-domain}` filter changes the repository, the asset or the channel, or turns updates off. It offers only tags such as `v2.1.0` or `v2.1.0-rc.1`, whose optional suffix is `-dev`, `-alpha`, `-beta` or `-rc` in lowercase, optionally numbered, because PHP's `version_compare()` ranks other suffixes above their release. ## Contract and versioning The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records. -The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too. +The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too. Releases follow SemVer on this contract from the first tag: a break of it ships as the next major, and internals change in any release. Plugins on different majors run side by side, because each ships its own prefixed copy. diff --git a/packages/bootstrap/src/Requirements.php b/packages/bootstrap/src/Requirements.php index 302c7b61..97b6223e 100644 --- a/packages/bootstrap/src/Requirements.php +++ b/packages/bootstrap/src/Requirements.php @@ -50,10 +50,15 @@ public static function check_floors( string $plugin_file ): bool { add_action( 'all_admin_notices', static function () use ( $plugin_file ): void { + $basename = plugin_basename( $plugin_file ); + if ( ! \is_file( WP_PLUGIN_DIR . "/$basename" ) ) { + return; // Core reads the headers from the plugins directory, so it has no message for a plugin loaded from elsewhere. + } + // Core translates the plugin's headers while it builds the message, so the message waits until the notice renders. - $requirements = validate_plugin_requirements( plugin_basename( $plugin_file ) ); + $requirements = validate_plugin_requirements( $basename ); if ( ! is_wp_error( $requirements ) ) { - return; // Core reads the headers from the plugins directory, so a plugin loaded from elsewhere reads as compatible. + return; } self::print_admin_notice( $requirements ); diff --git a/packages/bootstrap/src/Updater.php b/packages/bootstrap/src/Updater.php index 167de0b5..355f8558 100644 --- a/packages/bootstrap/src/Updater.php +++ b/packages/bootstrap/src/Updater.php @@ -9,6 +9,9 @@ * * @since 2.0.0 * @version 2.0.0 + * + * @phpstan-type Config array{repository: string, asset: string, channel: 'stable'|'prerelease', slug: string, name: string, version: string, author: string, homepage: string, file: string} + * @phpstan-type Release array{version: string, package: string, url: string, body: string, last_updated: string, requires: string, requires_php: string, tested: string} */ final class Updater { // region MAGIC METHODS @@ -59,6 +62,7 @@ static function ( $update, $plugin_data, $file ) use ( $plugin_file, $basename ) 'package' => $release['package'], 'requires' => $release['requires'], 'requires_php' => $release['requires_php'], + 'tested' => $release['tested'], ); }, 10, @@ -82,6 +86,8 @@ static function ( $result, $action, $args ) use ( $plugin_file ) { 'name' => $config['name'], 'slug' => $config['slug'], 'version' => $config['version'], + 'author' => $config['author'], + 'homepage' => $config['homepage'], 'sections' => array(), // WordPress's details screen reads the sections without a guard. ); @@ -92,7 +98,9 @@ static function ( $result, $action, $args ) use ( $plugin_file ) { 'download_link' => $release['package'], 'requires' => $release['requires'], 'requires_php' => $release['requires_php'], - 'sections' => array( 'changelog' => \nl2br( esc_html( $release['body'] ) ) ), + 'tested' => $release['tested'], + 'last_updated' => $release['last_updated'], + 'sections' => array( 'changelog' => $release['body'] ), // GitHub renders the notes as HTML, which WordPress's details screen passes through kses. ) + $information; } @@ -115,7 +123,7 @@ static function ( $result, $action, $args ) use ( $plugin_file ) { * * @param string $plugin_file The path to the plugin's entry file. * - * @return array{repository: string, asset: string, channel: 'stable'|'prerelease', slug: string, name: string, version: string, file: string}|null + * @return Config|null */ protected static function get_updater_config( string $plugin_file ): ?array { if ( ! \function_exists( 'get_plugin_data' ) ) { @@ -164,6 +172,8 @@ protected static function get_updater_config( string $plugin_file ): ?array { 'slug' => $slug, 'name' => $headers['Name'], 'version' => $headers['Version'], + 'author' => $headers['Author'], + 'homepage' => $headers['PluginURI'], 'file' => \basename( $plugin_file ), ); } @@ -176,9 +186,9 @@ protected static function get_updater_config( string $plugin_file ): ?array { * @since 2.0.0 * @version 2.0.0 * - * @param array{repository: string, asset: string, channel: 'stable'|'prerelease', slug: string, name: string, version: string, file: string} $config The plugin's updater configuration. + * @param Config $config The plugin's updater configuration. * - * @return array{version: string, tag: string, package: string, url: string, body: string, requires: string, requires_php: string}|null + * @return Release|null */ protected static function get_release( array $config ): ?array { $transient = "{$config['slug']}_github_release_{$config['channel']}"; @@ -192,7 +202,7 @@ protected static function get_release( array $config ): ?array { return $release; } - $body = self::get_response_body( "https://api.github.com/repos/{$config['repository']}/" . ( 'prerelease' === $config['channel'] ? 'releases?per_page=10' : 'releases/latest' ) ); + $body = self::get_response_body( "https://api.github.com/repos/{$config['repository']}/" . ( 'prerelease' === $config['channel'] ? 'releases?per_page=10' : 'releases/latest' ), array( 'Accept' => 'application/vnd.github.html+json' ) ); $releases = null === $body ? null : \json_decode( $body, true ); $newest = array(); @@ -204,7 +214,11 @@ protected static function get_release( array $config ): ?array { } $candidate_version = \ltrim( $candidate['tag_name'], 'v' ); - if ( 1 !== \preg_match( '/^\d+(?:\.\d+)*(?:-(?:dev|alpha|beta|rc)(?:\.?\d+)?)?$/i', $candidate_version ) ) { // PHP's version_compare() orders only these labels below their release, and ranks others, such as 'pre' or 'patch', above it. + if ( 1 !== \preg_match( '/^\d+(?:\.\d+)*(?:-(?:dev|alpha|beta|rc)(?:\.?\d+)?)?$/', $candidate_version ) ) { // PHP's version_compare() orders only these labels below their release as their names say, and misorders others, such as 'pre', 'patch' or 'Beta'. + continue; + } + + if ( 'stable' === $config['channel'] && false !== \strpos( $candidate_version, '-' ) ) { // GitHub's prerelease flag is set by hand, so a suffixed tag can be the latest release. continue; } @@ -230,21 +244,23 @@ protected static function get_release( array $config ): ?array { $headers = array( 'requires' => 'Requires at least', 'requires_php' => 'Requires PHP', + 'tested' => 'Tested up to', ); $requirements = array(); foreach ( $headers as $field => $header ) { $requirements[ $field ] = 1 === \preg_match( '/^(?:[ \t]*<\?(?:php)?)?[ \t\/*#@]*' . \preg_quote( $header, '/' ) . ':(.*)$/mi', $entry_file, $match ) ? \trim( (string) \preg_replace( '/\s*(?:\*\/|\?>).*/', '', $match[1] ) ) : ''; } - // WordPress prints the PHP requirement unescaped on its details screen, so anything but a version makes the release unusable. - if ( \preg_grep( '/^(?:\d+(?:\.\d+)*)?$/', $requirements ) === $requirements ) { + // WordPress prints the PHP requirement unescaped on its details screen, so a requirement that is not a version makes the release unusable, while the tested version passes through core's kses. + $checked = \array_diff_key( $requirements, array( 'tested' => true ) ); + if ( \preg_grep( '/^(?:\d+(?:\.\d+)*)?$/', $checked ) === $checked ) { $release = self::read_release( array( - 'version' => $version, - 'tag' => $tag, - 'package' => $package, - 'url' => $newest['html_url'] ?? null, - 'body' => $newest['body'] ?? '', + 'version' => $version, + 'package' => $package, + 'url' => $newest['html_url'] ?? null, + 'body' => $newest['body_html'] ?? '', + 'last_updated' => $newest['published_at'] ?? '', ) + $requirements ); } @@ -262,7 +278,7 @@ protected static function get_release( array $config ): ?array { * * @param mixed $value The candidate record. * - * @return array{version: string, tag: string, package: string, url: string, body: string, requires: string, requires_php: string}|null + * @return Release|null */ protected static function read_release( $value ): ?array { if ( ! \is_array( $value ) ) { @@ -270,7 +286,7 @@ protected static function read_release( $value ): ?array { } $release = array(); - foreach ( array( 'version', 'tag', 'package', 'url', 'body', 'requires', 'requires_php' ) as $field ) { + foreach ( array( 'version', 'package', 'url', 'body', 'last_updated', 'requires', 'requires_php', 'tested' ) as $field ) { $release[ $field ] = $value[ $field ] ?? ''; if ( ! \is_string( $release[ $field ] ) ) { return null; @@ -286,12 +302,13 @@ protected static function read_release( $value ): ?array { * @since 2.0.0 * @version 2.0.0 * - * @param string $url The URL. + * @param string $url The URL. + * @param array $headers Optional. The request's headers. Default none. * * @return string|null */ - protected static function get_response_body( string $url ): ?string { - $response = wp_remote_get( $url ); + protected static function get_response_body( string $url, array $headers = array() ): ?string { + $response = wp_remote_get( $url, array( 'headers' => $headers ) ); if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { return null; } diff --git a/packages/core/README.md b/packages/core/README.md index 72d8282b..efcc3920 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -192,7 +192,7 @@ final class Greeting implements ComponentInterface { The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records. -The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too. +The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too. The `DeepWebSolutions\Framework\Core\Scheduling\Backends\` namespace is internal, so a class there is internal even without its tag. diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php index 31329d90..387d9593 100644 --- a/packages/core/src/AdminNotice/AdminNotices.php +++ b/packages/core/src/AdminNotice/AdminNotices.php @@ -321,7 +321,7 @@ public function render_notices(): void { array( 'type' => $notice->type->value, 'dismissible' => $notice->dismissible, - 'id' => "{$this->prefix}-{$notice->scope->value}-notice-{$notice->id}", + 'id' => $this->get_dom_id( $notice ), 'attributes' => $attributes, ) ); @@ -390,6 +390,7 @@ public function enqueue_editor_script(): void { return; } + $handle = "{$this->prefix}-notice-delivery"; $settings = wp_json_encode( array( 'prefix' => $this->prefix, @@ -398,9 +399,9 @@ public function enqueue_editor_script(): void { ) ); - wp_register_script( "{$this->prefix}-notice-delivery", false, array( 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache. + wp_register_script( $handle, false, array( 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache. wp_add_inline_script( - "{$this->prefix}-notice-delivery", + $handle, <<<'JS' ( function ( settings ) { function deliver() { @@ -428,7 +429,7 @@ function deliver() { } ) JS . '( ' . $settings . ' );' ); - wp_enqueue_script( "{$this->prefix}-notice-delivery" ); + wp_enqueue_script( $handle ); } /** @@ -446,6 +447,7 @@ public function enqueue_dismiss_script(): void { return; } + $handle = "{$this->prefix}-notice-dismissal"; $settings = wp_json_encode( array( 'prefix' => $this->prefix, @@ -453,9 +455,9 @@ public function enqueue_dismiss_script(): void { ) ); - wp_register_script( "{$this->prefix}-notice-dismissal", false, array( 'wp-api-fetch' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache. + wp_register_script( $handle, false, array( 'wp-api-fetch' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache. wp_add_inline_script( - "{$this->prefix}-notice-dismissal", + $handle, <<<'JS' ( function ( settings ) { document.addEventListener( 'click', function ( event ) { @@ -480,7 +482,7 @@ public function enqueue_dismiss_script(): void { } ) JS . '( ' . $settings . ' );' ); - wp_enqueue_script( "{$this->prefix}-notice-dismissal" ); + wp_enqueue_script( $handle ); } /** @@ -558,7 +560,7 @@ public function rest_deliver(): array { $delivered = array(); foreach ( $this->select( true ) as $notice ) { $delivered[] = array( - 'id' => "{$this->prefix}-{$notice->scope->value}-notice-{$notice->id}", + 'id' => $this->get_dom_id( $notice ), 'status' => $notice->type->value, 'content' => wp_kses_post( $this->get_message( $notice ) ), 'isDismissible' => $notice->dismissible, @@ -812,6 +814,20 @@ protected function log( string $message, NoticeScope $scope, \Throwable $excepti ); } + /** + * Returns the id of the notice's element, which the classic screens and the block editor share. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param Notice $notice The notice. + * + * @return string + */ + protected function get_dom_id( Notice $notice ): string { + return "{$this->prefix}-{$notice->scope->value}-notice-{$notice->id}"; + } + /** * Returns the REST namespace. * diff --git a/packages/core/src/Caching/CacheEntries.php b/packages/core/src/Caching/CacheEntries.php index b75eab4c..21a89d35 100644 --- a/packages/core/src/Caching/CacheEntries.php +++ b/packages/core/src/Caching/CacheEntries.php @@ -62,7 +62,7 @@ public static function check_key( mixed $key ): string { if ( ! \is_string( $key ) || '' === $key || false !== \strpbrk( $key, self::RESERVED_CHARACTERS ) ) { $shown = \is_string( $key ) ? $key : \get_debug_type( $key ); - throw new InvalidCacheKeyException( "Cache key '$shown' is empty, not a string or holds one of {}()/\\@:." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + throw new InvalidCacheKeyException( "Cache key '$shown' is empty, not a string or holds one of " . self::RESERVED_CHARACTERS . '.' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. } return $key; @@ -129,7 +129,9 @@ public static function wrap( mixed $value, string $generation ): array { } /** - * Returns the value of the entry wrapped in a list, or null when the entry is not one of the generation. + * Returns the value of the entry wrapped in a list, or null when the entry is not one of the generation or names a class that no longer loads. + * + * A class that no longer loads would read back as __PHP_Incomplete_Class, which PSR-16 counts as a value the cache cannot return. A string value that only looks like an object token reads as a miss. * * @since 2.0.0 * @version 2.0.0 @@ -146,7 +148,15 @@ public static function unwrap( mixed $entry, string $generation ): ?array { } $value = $entry['value']; - return array( \is_string( $value ) ? maybe_unserialize( $value ) : $value ); + if ( ! \is_string( $value ) ) { + return array( $value ); + } + + if ( is_serialized( $value ) && 0 < \preg_match_all( '/[OCE]:\d+:"([^"]+)"/', $value, $classes ) && ! \array_all( $classes[1], static fn ( string $token ): bool => \class_exists( \explode( ':', $token )[0] ) ) ) { + return null; + } + + return array( maybe_unserialize( $value ) ); } // endregion diff --git a/packages/core/src/Logging/AbstractLogger.php b/packages/core/src/Logging/AbstractLogger.php index 3567c59b..415e2c27 100644 --- a/packages/core/src/Logging/AbstractLogger.php +++ b/packages/core/src/Logging/AbstractLogger.php @@ -72,9 +72,9 @@ protected static function get_default_threshold(): int { } /** - * Replaces each {key} placeholder in the message with its scalar, null or Stringable context value. + * Replaces each {key} placeholder in the message with its scalar, null or Stringable context value, with its line breaks escaped. * - * Only the values the message uses are cast, and a placeholder whose value throws while casting stays as it is, because PSR-3 forbids a context value from making logging fail. + * Only the values the message uses are cast, and a placeholder whose value throws while casting stays as it is, because PSR-3 forbids a context value from making logging fail. Line breaks are escaped so that a value cannot start a forged record. * * @since 2.0.0 * @version 2.0.0 @@ -93,7 +93,7 @@ protected static function interpolate( string $message, array $context ): string } try { - $replacements[ $placeholder ] = (string) $value; + $replacements[ $placeholder ] = \str_replace( array( "\r", "\n" ), array( '\r', '\n' ), (string) $value ); } catch ( \Throwable ) { continue; } diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php index 6da1fad4..b00daf60 100644 --- a/packages/core/src/Migration/MigrationRunner.php +++ b/packages/core/src/Migration/MigrationRunner.php @@ -234,8 +234,14 @@ public function run(): void { ); $revision = $target; } elseif ( LockClaim::TakenOver === $claim && \is_null( $this->get_error() ) ) { - $this->record_error( 'Step ' . ( $revision + 1 ) . ' did not finish within 10 minutes.', $revision ); - $this->logger->warning( 'Data migration step {step} did not finish within 10 minutes, so it runs again.', array( 'step' => $revision + 1 ) ); + $this->record_error( 'Step ' . ( $revision + 1 ) . ' did not finish within ' . self::LOCK_TTL / MINUTE_IN_SECONDS . ' minutes.', $revision ); + $this->logger->warning( + 'Data migration step {step} did not finish within {minutes} minutes, so it runs again.', + array( + 'step' => $revision + 1, + 'minutes' => self::LOCK_TTL / MINUTE_IN_SECONDS, + ) + ); } if ( \is_null( $row ) ) { @@ -269,7 +275,9 @@ public function run(): void { $finished = true; if ( $step instanceof BatchedMigrationStepInterface ) { $finished = ! $step->process_batch(); - wp_cache_flush_runtime(); + if ( wp_cache_supports( 'flush_runtime' ) ) { + wp_cache_flush_runtime(); + } } else { $step(); } @@ -300,7 +308,7 @@ public function run(): void { ) ); - // In production the lock stays until its hold expires, so a failing step runs again 10 minutes later; elsewhere the fixed step runs on the next request. + // In production the lock stays until its hold expires, when a failing step runs again; elsewhere the fixed step runs on the next request. if ( PluginBoot::is_development_environment() ) { $this->unlock(); throw $exception; @@ -317,7 +325,7 @@ public function run(): void { // region HOOKS /** - * Tells users who can activate plugins, while steps are pending, that the last run failed or that a batched step is running in the background. + * Tells users who can activate plugins, while steps are pending, that the last run failed or that a batched step is running in the background, with a link that runs a pending batched step now. * * @since 2.0.0 * @version 2.0.0 @@ -330,27 +338,37 @@ public function render_notice(): void { return; } - $name = get_plugin_data( $this->plugin_file, false )['Name']; - $error = $this->get_error(); + $name = get_plugin_data( $this->plugin_file, false )['Name']; + $error = $this->get_error(); + $batched = $this->steps[ $revision ] instanceof BatchedMigrationStepInterface; + $run_now = esc_url( wp_nonce_url( admin_url( "admin-post.php?action={$this->get_hook()}_now" ), "{$this->get_hook()}_now" ) ); if ( ! \is_null( $error ) ) { Requirements::print_admin_notice( - \sprintf( - /* translators: 1: The plugin's name. 2: The error message. */ - esc_html__( '%1$s could not update its data, so features that need the update stay off. Error: %2$s', 'wp-framework' ), - esc_html( $name ), - esc_html( $error['message'] ) - ) + $batched + ? \sprintf( + /* translators: 1: The plugin's name. 2: The error message. 3: The URL that runs the update now. */ + __( '%1$s could not update its data, so features that need the update stay off. Error: %2$s Run the update now.', 'wp-framework' ), + esc_html( $name ), + esc_html( $error['message'] ), + $run_now + ) + : \sprintf( + /* translators: 1: The plugin's name. 2: The error message. */ + esc_html__( '%1$s could not update its data, so features that need the update stay off. Error: %2$s', 'wp-framework' ), + esc_html( $name ), + esc_html( $error['message'] ) + ) ); return; } - if ( $this->steps[ $revision ] instanceof BatchedMigrationStepInterface ) { + if ( $batched ) { Requirements::print_admin_notice( \sprintf( /* translators: 1: The plugin's name. 2: The URL that runs the update now. */ __( '%1$s is updating its data in the background. Run the update now.', 'wp-framework' ), esc_html( $name ), - esc_url( wp_nonce_url( admin_url( "admin-post.php?action={$this->get_hook()}_now" ), "{$this->get_hook()}_now" ) ) + $run_now ), array( 'type' => 'info' ) ); @@ -433,8 +451,9 @@ protected function unlock(): void { $this->lock->release( $this->owner ); } catch ( RuntimeException $exception ) { $this->logger->warning( - 'Could not release the data migration lock, which expires in 10 minutes: {error}', + 'Could not release the data migration lock, which expires in {minutes} minutes: {error}', array( + 'minutes' => self::LOCK_TTL / MINUTE_IN_SECONDS, 'error' => $exception->getMessage(), 'exception' => $exception, ) @@ -445,7 +464,7 @@ protected function unlock(): void { /** * Schedules the runner's next invocation, and logs a scheduler failure, which a later request retries. * - * Inside its own hook the running invocation would satisfy enqueue(), so the continuation is a single invocation due a second later, and the queue run dispatching it ends instead of claiming it again at once. + * Inside its own hook a running Action Scheduler invocation satisfies the enqueue, so the next request or process continues, and a queue run that keeps claiming, as wp action-scheduler run does, finds nothing of the runner's due and ends. * * @since 2.0.0 * @version 2.0.0 @@ -453,7 +472,7 @@ protected function unlock(): void { * @return void */ protected function schedule_continuation(): void { - $scheduled = doing_action( $this->get_hook() ) ? $this->scheduler->schedule_single( $this->scheduled_hook, \time() + 1 ) : $this->scheduler->enqueue( $this->scheduled_hook ); + $scheduled = $this->scheduler->enqueue( $this->scheduled_hook ); if ( true !== $scheduled ) { $this->logger->warning( 'Could not schedule the next data migration run: {error}', diff --git a/packages/core/src/PluginKernel.php b/packages/core/src/PluginKernel.php index 671be3d3..ced7a9d6 100644 --- a/packages/core/src/PluginKernel.php +++ b/packages/core/src/PluginKernel.php @@ -18,16 +18,6 @@ final class PluginKernel { // region FIELDS AND CONSTANTS - /** - * The classes constructed, in walk order, whether registration reached them. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @var list> - */ - protected(set) array $resolved = array(); - /** * The classes whose gate returned false. * @@ -126,8 +116,6 @@ protected function resolve( array $classes ): array { throw new LogicException( "The container entry '$class' has type '" . \get_debug_type( $component ) . "', not that class." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. } - $this->resolved[] = $class; - $components[] = $component; if ( \is_a( $class, CompositeComponentInterface::class, true ) ) { \array_push( $components, ...$this->resolve( $class::get_child_component_classes() ) ); // The declared class names the children, so no runtime state can add an unvisited node. diff --git a/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php b/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php index 890a3552..d1d6f8b5 100644 --- a/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php +++ b/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php @@ -63,7 +63,8 @@ public function get_state(): BackendState { } if ( 0 === did_action( 'action_scheduler_init' ) ) { - return BackendState::Initializing; + // Every version from 3.5.5 fires the hook during init, so one that has not by then is older. + return 0 < did_action( 'init' ) && ! doing_action( 'init' ) ? BackendState::Unsupported : BackendState::Initializing; } $version = \ActionScheduler_Versions::instance()->latest_version(); diff --git a/packages/core/src/Scheduling/Scheduler.php b/packages/core/src/Scheduling/Scheduler.php index ec8de585..28187951 100644 --- a/packages/core/src/Scheduling/Scheduler.php +++ b/packages/core/src/Scheduling/Scheduler.php @@ -44,6 +44,16 @@ final class Scheduler implements SchedulerInterface, ComponentInterface { */ protected WpCronBackend $wp_cron; + /** + * The id of the site the scheduler was built on, the only site it schedules for. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var int + */ + protected int $blog_id; + // endregion // region MAGIC METHODS @@ -65,6 +75,7 @@ public function __construct( string $group ) { $this->wp_cron = new WpCronBackend( $group ); $this->backends = array( new ActionSchedulerBackend( $group ), $this->wp_cron ); + $this->blog_id = get_current_blog_id(); } // endregion @@ -251,8 +262,8 @@ protected function check_ready( string $method ): true|\WP_Error { $error = match ( true ) { 0 === did_action( 'init' ) || \array_any( $this->backends, static fn ( BackendInterface $backend ): bool => BackendState::Initializing === $backend->get_state() ) => new \WP_Error( 'scheduler_not_ready', 'Scheduling waits until init has fired and Action Scheduler has initialized.' ), - // Readiness is measured for the site that loaded the plugins, while a switched site may run other plugins or lack Action Scheduler's tables. - is_multisite() && ms_is_switched() + // Readiness is measured for the site the scheduler was built on, while another site may run other plugins or lack Action Scheduler's tables. + get_current_blog_id() !== $this->blog_id => new \WP_Error( 'scheduler_switched', 'Scheduling is not supported while switched to another site.' ), default => true, }; diff --git a/packages/core/src/Storage/OptionRows.php b/packages/core/src/Storage/OptionRows.php index d24d79b7..99978020 100644 --- a/packages/core/src/Storage/OptionRows.php +++ b/packages/core/src/Storage/OptionRows.php @@ -95,6 +95,7 @@ public static function insert( string $name, mixed $value ): ?OptionRow { global $wpdb; $raw = self::serialize( $name, $value ); + self::check_name( $name ); $inserted = self::write( $wpdb->prepare( 'INSERT IGNORE INTO %i (option_name, option_value, autoload) VALUES (%s, %s, %s)', $wpdb->options, $name, $raw, 'off' ) ?? '', "Could not insert option '$name'" ); if ( 0 === $inserted ) { @@ -116,7 +117,7 @@ public static function insert( string $name, mixed $value ): ?OptionRow { * @param OptionRow $observed The row as it was read. * @param mixed $value The new value: a scalar, null, or an array of them, stored other than the observed one. * - * @throws InvalidArgumentException Thrown when the value is invalid or would be stored as the observed one, or the row's name is invalid. + * @throws InvalidArgumentException Thrown when the value is invalid or would be stored as the observed one. * @throws RuntimeException Thrown when the write fails. * * @return OptionRow|null @@ -198,35 +199,19 @@ public static function check_name( string $name, int $max_length = self::MAX_NAM * @param string $name The option name. * @param mixed $value The value. * - * @throws InvalidArgumentException Thrown when the name is invalid or the value holds anything but scalars, null and arrays. + * @throws InvalidArgumentException Thrown when the value holds anything but scalars, null and arrays. * * @return string */ protected static function serialize( string $name, mixed $value ): string { - if ( ! self::is_storable( $value ) ) { // A stored object's class name changes with each plugin's scoping prefix, so it would not read back. + if ( ! StoredData::is_storable_data( $value ) ) { throw new InvalidArgumentException( "The value of option '$name' holds something other than scalars, null and arrays." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. } - self::check_name( $name ); - $raw = maybe_serialize( $value ); return \is_scalar( $raw ) ? (string) $raw : ''; } - /** - * Returns whether the value holds only scalars, null and arrays of them. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param mixed $value The value. - * - * @return bool - */ - protected static function is_storable( mixed $value ): bool { - return \is_null( $value ) || \is_scalar( $value ) || ( \is_array( $value ) && \array_all( $value, self::is_storable( ... ) ) ); - } - /** * Returns a row from its stored value. * diff --git a/packages/core/src/Storage/StoredData.php b/packages/core/src/Storage/StoredData.php new file mode 100644 index 00000000..f4ac790f --- /dev/null +++ b/packages/core/src/Storage/StoredData.php @@ -0,0 +1,69 @@ +|null $value + * + * @param mixed $value The value. + * + * @return bool + */ + public static function is_storable_data( mixed $value ): bool { + return \is_null( $value ) || \is_scalar( $value ) || ( \is_array( $value ) && \array_all( $value, self::is_storable_data( ... ) ) ); + } + + /** + * Throws unless every value holds only scalars, null and arrays of them, naming the first key whose value does not. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param array $values The values, keyed by their keys. + * + * @throws InvalidArgumentException Thrown when a value holds anything but scalars, null and arrays. + * + * @return void + */ + public static function check_values( array $values ): void { + foreach ( $values as $key => $value ) { + if ( ! self::is_storable_data( $value ) ) { + throw new InvalidArgumentException( "The value of key '$key' holds something other than scalars, null and arrays." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } + } + } + + // endregion +} diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php index 01f3b3de..ffd6bd80 100644 --- a/packages/core/src/Uninstall/Uninstaller.php +++ b/packages/core/src/Uninstall/Uninstaller.php @@ -18,6 +18,16 @@ final class Uninstaller { // region FIELDS AND CONSTANTS + /** + * The number of row ids one read takes, so that a key on very many rows clears with flat memory. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var int + */ + protected const int ID_PAGE_SIZE = 1000; + /** * The failures of the run so far. * @@ -38,15 +48,19 @@ final class Uninstaller { * @since 2.0.0 * @version 2.0.0 * + * @phpstan-param array> $footprint + * @phpstan-param array{meta: array>, meta_prefixes: array>} $meta_footprint * @phpstan-param array{consent: array{option: string, key: string, constant: string}, meta_types: array, cache_groups: list}>, cleanup: (callable(array{scope: 'site'|'network', site_id: int, plugin_file: string}): void)|null} $args * - * @param string $plugin_file The path to the plugin's entry file. - * @param array $footprint The plugin's stored data by kind. - * @param array $args The consent, the meta types by name and the cleanup. + * @param string $plugin_file The path to the plugin's entry file. + * @param array $footprint The names and prefixes of every kind but the meta kinds, by kind. + * @param array $meta_footprint The meta keys and meta prefixes, by meta type name. + * @param array $args The consent, the meta types by name and the cleanup. */ protected function __construct( protected readonly string $plugin_file, protected readonly array $footprint, + protected readonly array $meta_footprint, protected readonly array $args ) {} @@ -104,7 +118,24 @@ public static function run( string $plugin_file, array $footprint, array $args = } $args['meta_types'] = $meta_types; - $uninstaller = new self( $plugin_file, $footprint, $args ); + $uninstaller = new self( + $plugin_file, + \array_map( + self::get_strings( ... ), + \array_diff_key( + $footprint, + array( + 'meta' => true, + 'meta_prefixes' => true, + ) + ) + ), + array( + 'meta' => self::get_meta_strings( $footprint['meta'] ?? null, $meta_types ), + 'meta_prefixes' => self::get_meta_strings( $footprint['meta_prefixes'] ?? null, $meta_types ), + ), + $args + ); if ( ! is_multisite() ) { $uninstaller->uninstall_site( true ); } else { @@ -166,7 +197,7 @@ protected function uninstall_site( bool $network_footprint ): void { $failed = \count( $this->failures ); try { - foreach ( self::get_strings( $this->footprint['cron_hooks'] ?? null ) as $hook ) { + foreach ( $this->footprint['cron_hooks'] ?? array() as $hook ) { $wpdb->flush(); try { $result = wp_unschedule_hook( $hook, true ); @@ -178,10 +209,10 @@ protected function uninstall_site( bool $network_footprint ): void { if ( \function_exists( 'as_unschedule_all_actions' ) ) { // Action Scheduler is loaded only while a plugin that bundles it is active. $jobs = array(); - foreach ( self::get_strings( $this->footprint['as_hooks'] ?? null ) as $hook ) { + foreach ( $this->footprint['as_hooks'] ?? array() as $hook ) { $jobs[ "Action Scheduler hook $hook" ] = array( $hook ); } - foreach ( self::get_strings( $this->footprint['as_groups'] ?? null ) as $group ) { + foreach ( $this->footprint['as_groups'] ?? array() as $group ) { $jobs[ "Action Scheduler group $group" ] = array( '', array(), $group ); } @@ -215,8 +246,8 @@ protected function uninstall_site( bool $network_footprint ): void { return; } - $options = self::get_strings( $this->footprint['options'] ?? null ); - foreach ( self::get_strings( $this->footprint['option_prefixes'] ?? null ) as $prefix ) { + $options = $this->footprint['options'] ?? array(); + foreach ( $this->footprint['option_prefixes'] ?? array() as $prefix ) { $options = \array_merge( $options, $this->get_names_by_prefix( $wpdb->options, 'option_name', $prefix ) ); } foreach ( \array_diff( \array_unique( $options ), array( $this->args['consent']['option'] ) ) as $option ) { @@ -224,27 +255,32 @@ protected function uninstall_site( bool $network_footprint ): void { $this->record_failure( "option $option", delete_option( $option ) ); } - foreach ( self::get_strings( $this->footprint['transients'] ?? null ) as $transient ) { + foreach ( $this->footprint['transients'] ?? array() as $transient ) { $wpdb->flush(); $this->record_failure( "transient $transient", delete_transient( $transient ) ); } - foreach ( self::get_strings( $this->footprint['post_types'] ?? null ) as $post_type ) { + foreach ( $this->footprint['post_types'] ?? array() as $post_type ) { + // A relationship carries no object type, so only post taxonomies are read, which leaves a link's or a user's terms under the same id alone. + $taxonomies = get_object_taxonomies( array( $post_type, ...get_post_types() ) ); foreach ( $this->get_matching_ids( $wpdb->posts, 'ID', 'post_type', $post_type ) as $post_id ) { $wpdb->flush(); - $this->record_failure( "post $post_id of type $post_type", self::confirm_deleted( wp_delete_post( $post_id, true ) ) ); + // WordPress removes only the terms of the taxonomies registered for the post's type, which its inactive plugin registered, so the rest go once the post is gone. + if ( $this->record_failure( "post $post_id of type $post_type", self::confirm_deleted( wp_delete_post( $post_id, true ) ) ) ) { + wp_delete_object_term_relationships( $post_id, $taxonomies ); + } } } - foreach ( self::get_strings( $this->footprint['comment_types'] ?? null ) as $comment_type ) { + foreach ( $this->footprint['comment_types'] ?? array() as $comment_type ) { foreach ( $this->get_matching_ids( $wpdb->comments, 'comment_ID', 'comment_type', $comment_type ) as $comment_id ) { $wpdb->flush(); $this->record_failure( "comment $comment_id of type $comment_type", self::confirm_deleted( wp_delete_comment( $comment_id, true ) ) ); } } - $meta_keys = \array_intersect_key( \is_array( $this->footprint['meta'] ?? null ) ? $this->footprint['meta'] : array(), $this->args['meta_types'] ); - $meta_prefixes = \array_intersect_key( \is_array( $this->footprint['meta_prefixes'] ?? null ) ? $this->footprint['meta_prefixes'] : array(), $this->args['meta_types'] ); + $meta_keys = $this->meta_footprint['meta']; + $meta_prefixes = $this->meta_footprint['meta_prefixes']; foreach ( \array_keys( $meta_keys + $meta_prefixes ) as $name ) { $meta_type = $this->args['meta_types'][ $name ]; @@ -257,8 +293,8 @@ protected function uninstall_site( bool $network_footprint ): void { } } - $keys = self::get_strings( $meta_keys[ $name ] ?? null ); - foreach ( self::get_strings( $meta_prefixes[ $name ] ?? null ) as $prefix ) { + $keys = $meta_keys[ $name ] ?? array(); + foreach ( $meta_prefixes[ $name ] ?? array() as $prefix ) { foreach ( '' === $meta_type['meta_type'] ? $tables : array( (string) _get_meta_table( $meta_type['meta_type'] ), ...$tables ) as $table ) { $keys = \array_merge( $keys, $this->get_names_by_prefix( $table, 'meta_key', $prefix ) ); } @@ -284,12 +320,12 @@ protected function uninstall_site( bool $network_footprint ): void { } $blog_prefix = $wpdb->get_blog_prefix(); // Site user meta keys carry the site's table prefix, as update_user_option() writes them. - $this->delete_meta_keys( 'user', \array_map( static fn ( string $key ): string => $blog_prefix . $key, self::get_strings( $this->footprint['user_meta_site'] ?? null ) ) ); - foreach ( self::get_strings( $this->footprint['user_meta_site_prefixes'] ?? null ) as $prefix ) { + $this->delete_meta_keys( 'user', \array_map( static fn ( string $key ): string => $blog_prefix . $key, $this->footprint['user_meta_site'] ?? array() ) ); + foreach ( $this->footprint['user_meta_site_prefixes'] ?? array() as $prefix ) { $this->delete_meta_keys( 'user', $this->get_names_by_prefix( $wpdb->usermeta, 'meta_key', $blog_prefix . $prefix ) ); } - foreach ( self::get_strings( $this->footprint['capabilities'] ?? null ) as $capability ) { + foreach ( $this->footprint['capabilities'] ?? array() as $capability ) { foreach ( wp_roles()->role_objects as $role ) { $wpdb->flush(); $role->remove_cap( $capability ); @@ -297,7 +333,7 @@ protected function uninstall_site( bool $network_footprint ): void { } } - foreach ( self::get_strings( $this->footprint['tables'] ?? null ) as $table ) { + foreach ( $this->footprint['tables'] ?? array() as $table ) { $wpdb->flush(); $this->record_failure( "table $table", $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . $table ) ?? '' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- A plugin's own table, which no WordPress API drops. } @@ -329,7 +365,7 @@ protected function uninstall_site( bool $network_footprint ): void { protected function delete_network_footprint(): void { global $wpdb; - $options = self::get_strings( $this->footprint['network_options'] ?? null ); + $options = $this->footprint['network_options'] ?? array(); if ( is_multisite() ) { // WordPress's delete_network_option() matches keys under the column's collation, which would also delete another plugin's key that differs only in letter case. $this->delete_meta_keys( 'site', $options ); @@ -343,8 +379,8 @@ protected function delete_network_footprint(): void { } } - $this->delete_meta_keys( 'user', self::get_strings( $this->footprint['user_meta_global'] ?? null ) ); - foreach ( self::get_strings( $this->footprint['user_meta_global_prefixes'] ?? null ) as $prefix ) { + $this->delete_meta_keys( 'user', $this->footprint['user_meta_global'] ?? array() ); + foreach ( $this->footprint['user_meta_global_prefixes'] ?? array() as $prefix ) { $this->delete_meta_keys( 'user', $this->get_names_by_prefix( $wpdb->usermeta, 'meta_key', $prefix ) ); } } @@ -472,7 +508,9 @@ protected function get_names_by_prefix( string $table, string $column, string $p } /** - * Returns the ids of the rows whose column holds exactly the value, and records a failed read. + * Returns the ids of the rows whose column holds exactly the value, read a page at a time after the last id read, and records a failed read. + * + * The caller deletes each page's rows before the next page is read, so memory stays flat however many rows match. * * @since 2.0.0 * @version 2.0.0 @@ -484,26 +522,31 @@ protected function get_names_by_prefix( string $table, string $column, string $p * @param string $column The column to match. * @param string $value The value, matched byte for byte. * - * @return list + * @return \Generator */ - protected function get_matching_ids( string $table, string $id_column, string $column, string $value ): array { + protected function get_matching_ids( string $table, string $id_column, string $column, string $value ): \Generator { global $wpdb; - $wpdb->flush(); - $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT %i AS id, %i AS value FROM %i WHERE %i = %s', $id_column, $column, $table, $column, $value ) ?? '', ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads every row, trashed posts and auto-drafts included. - if ( ! $this->record_failure( "the $value rows of $table", $rows ) ) { - return array(); - } - - $ids = array(); - foreach ( (array) $rows as $row ) { - // The column's collation also matches values that differ in letter case or accents. - if ( \is_array( $row ) && ( $row['value'] ?? null ) === $value && \is_numeric( $row['id'] ?? null ) ) { - $ids[] = (int) $row['id']; + $last = 0; + do { + $wpdb->flush(); + $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT %i AS id, %i AS value FROM %i WHERE %i = %s AND %i > %d ORDER BY %i LIMIT %d', $id_column, $column, $table, $column, $value, $id_column, $last, $id_column, self::ID_PAGE_SIZE ) ?? '', ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads every row, trashed posts and auto-drafts included. + if ( ! $this->record_failure( "the $value rows of $table", $rows ) ) { + return; } - } - return $ids; + $rows = (array) $rows; + $full = self::ID_PAGE_SIZE === \count( $rows ); + foreach ( $rows as $row ) { + if ( \is_array( $row ) && \is_numeric( $row['id'] ?? null ) ) { + $last = (int) $row['id']; + // The column's collation also matches values that differ in letter case or accents. + if ( ( $row['value'] ?? null ) === $value ) { + yield $last; + } + } + } + } while ( $full ); } /** @@ -550,5 +593,20 @@ protected static function get_strings( mixed $value ): array { return \array_values( \array_filter( \is_array( $value ) ? $value : array(), static fn ( mixed $item ): bool => \is_string( $item ) && '' !== $item && '0' !== $item ) ); } + /** + * Returns a meta kind's strings for each of the meta types the run knows, or none when it is not an array. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param mixed $value The meta kind's entries, by meta type name. + * @param array $meta_types The run's meta types, by name. + * + * @return array> + */ + protected static function get_meta_strings( mixed $value, array $meta_types ): array { + return \array_map( self::get_strings( ... ), \array_intersect_key( \is_array( $value ) ? $value : array(), $meta_types ) ); + } + // endregion } diff --git a/packages/settings/README.md b/packages/settings/README.md index 33ec754c..bdd5e927 100644 --- a/packages/settings/README.md +++ b/packages/settings/README.md @@ -150,7 +150,7 @@ final class Settings implements OptionsPageDefinitionInterface { The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records. -The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too. +The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too. Releases follow SemVer on this contract from the first tag: a break of it ships as the next major, and internals change in any release. Plugins on different majors run side by side, because each ships its own prefixed copy. diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php index a19512d2..12ac8035 100644 --- a/packages/settings/src/AdminPage/OptionsPage.php +++ b/packages/settings/src/AdminPage/OptionsPage.php @@ -190,7 +190,7 @@ public function render_page(): void { $option = $tab['option']; $stored = get_option( $option ); foreach ( Forms::get_rows( $tab['fields'], \is_array( $stored ) ? $stored : array(), $option ) as $row ) { - add_settings_field( $row['field']['id'] ?? '', esc_html( $row['field']['title'] ?? '' ), array( $this, 'render_field' ), $this->id, 'default', $row ); + add_settings_field( $row['field']['id'], esc_html( $row['field']['title'] ), array( $this, 'render_field' ), $this->id, 'default', $row ); } echo '
'; diff --git a/packages/settings/src/EditScreen/Hosts/TermForm.php b/packages/settings/src/EditScreen/Hosts/TermForm.php index d01bf49c..39bcebd6 100644 --- a/packages/settings/src/EditScreen/Hosts/TermForm.php +++ b/packages/settings/src/EditScreen/Hosts/TermForm.php @@ -17,8 +17,6 @@ * * @since 2.0.0 * @version 2.0.0 - * - * @phpstan-import-type Row from ObjectFormHostInterface */ final class TermForm implements ObjectFormHostInterface { // region FIELDS AND CONSTANTS diff --git a/packages/settings/src/EditScreen/Hosts/UserProfile.php b/packages/settings/src/EditScreen/Hosts/UserProfile.php index d20406e5..80264c08 100644 --- a/packages/settings/src/EditScreen/Hosts/UserProfile.php +++ b/packages/settings/src/EditScreen/Hosts/UserProfile.php @@ -17,8 +17,6 @@ * * @since 2.0.0 * @version 2.0.0 - * - * @phpstan-import-type Row from ObjectFormHostInterface */ final class UserProfile implements ObjectFormHostInterface { // region FIELDS AND CONSTANTS diff --git a/packages/settings/src/EditScreen/ObjectForm.php b/packages/settings/src/EditScreen/ObjectForm.php index 7092ee6a..f4c81617 100644 --- a/packages/settings/src/EditScreen/ObjectForm.php +++ b/packages/settings/src/EditScreen/ObjectForm.php @@ -174,7 +174,7 @@ public function render( int $object_id ): void { $control = Forms::render_control( $row['field'], $row['value'], $row['name'], $row['id'] ); if ( '' !== $control ) { $rows[] = array( - 'title' => esc_html( $row['field']['title'] ?? '' ), + 'title' => esc_html( $row['field']['title'] ), 'label_for' => $row['label_for'] ?? null, 'control' => $control, ); diff --git a/packages/settings/src/EditScreen/ObjectStoreInterface.php b/packages/settings/src/EditScreen/ObjectStoreInterface.php index 0d700829..6c58ab55 100644 --- a/packages/settings/src/EditScreen/ObjectStoreInterface.php +++ b/packages/settings/src/EditScreen/ObjectStoreInterface.php @@ -2,6 +2,8 @@ namespace DeepWebSolutions\Framework\Settings\EditScreen; +use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; + \defined( 'ABSPATH' ) || exit; /** @@ -25,13 +27,15 @@ interface ObjectStoreInterface { public function get_values( int $object_id, array $keys ): array; /** - * Stores the values under their keys for the object, each as given. + * Stores the values under their keys for the object, each as given and each a scalar, null or an array of them. * * @since 2.0.0 * @version 2.0.0 * - * @param int $object_id The object's id. - * @param array $values The values, keyed by their keys. + * @param int $object_id The object's id. + * @param array|null> $values The values, keyed by their keys. + * + * @throws InvalidArgumentException Thrown when a value holds anything but scalars, null and arrays, before any value is stored. * * @return void */ diff --git a/packages/settings/src/EditScreen/Stores/MetadataStore.php b/packages/settings/src/EditScreen/Stores/MetadataStore.php index ce05533d..18a4a10d 100644 --- a/packages/settings/src/EditScreen/Stores/MetadataStore.php +++ b/packages/settings/src/EditScreen/Stores/MetadataStore.php @@ -2,7 +2,9 @@ namespace DeepWebSolutions\Framework\Settings\EditScreen\Stores; +use DeepWebSolutions\Framework\Core\Storage\StoredData; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectStoreInterface; +use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; \defined( 'ABSPATH' ) || exit; @@ -55,9 +57,12 @@ public function get_values( int $object_id, array $keys ): array { * * @since 2.0.0 * @version 2.0.0 + * + * @throws InvalidArgumentException Thrown when a value holds anything but scalars, null and arrays, before any value is stored. */ #[\Override] public function update_values( int $object_id, array $values ): void { + StoredData::check_values( $values ); // The metadata API rewrites an object's public properties as it unslashes them. foreach ( $values as $key => $value ) { update_metadata( $this->meta_type, $object_id, $key, wp_slash( $value ) ); // The metadata API unslashes what it is given, which would strip a value's own backslashes. } diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php index 566f279a..08ad0c9d 100644 --- a/packages/settings/src/Forms.php +++ b/packages/settings/src/Forms.php @@ -14,8 +14,8 @@ * @since 2.0.0 * @version 2.0.0 * - * @phpstan-type Field array{type: string, id?: string, title?: string, desc?: string, desc_tip?: bool|string, default?: mixed, options?: array, placeholder?: string, class?: string, css?: string, custom_attributes?: array, disabled?: bool|list, validate?: callable(mixed): mixed, render?: callable(array, mixed, string, string): string} - * @phpstan-type FieldRow array{field: Field, value: mixed, name: string, id: string, label_for?: string} + * @phpstan-type Field array{type: string, id?: string, title?: string, desc?: string, desc_tip?: bool|string, default?: mixed, options?: array, placeholder?: string, class?: string, css?: string, custom_attributes?: array, disabled?: bool|list, validate?: callable(mixed): (scalar|array|\WP_Error|null), render?: callable(array, mixed, string, string): string} + * @phpstan-type FieldRow array{field: Field&array{id: string, title: string}, value: mixed, name: string, id: string, label_for?: string} */ final class Forms { // region FIELDS AND CONSTANTS @@ -96,57 +96,18 @@ public static function render_control( array $field, mixed $value, string $name, }; } - /** - * Returns the value to store for the field's submission, or a WP_Error under which the host keeps the stored value. - * - * A field with validate hands the submission, alone, to the callable, which returns the value to store or a WP_Error and owns the field's allowed values, so no type sanitizer runs before or after it. Without validate, the field's type sanitizes the submission and checks its choices, and a rejection carries a code and a message that tells the user what to enter, which a host may replace by the code. Every WP_Error message is plain text, which the host escapes. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param Field $field The field. - * @param mixed $raw The unslashed submission, in which a cleared multi-value field is an empty string. - * - * @return mixed|\WP_Error - */ - #[\NoDiscard] - public static function sanitize_value( array $field, mixed $raw ): mixed { - if ( '' === $raw && \in_array( $field['type'], array( 'multiselect', 'checkboxes' ), true ) ) { - $raw = array(); - } - - if ( isset( $field['validate'] ) ) { - return $field['validate']( $raw ); - } - if ( isset( $field['render'] ) ) { - return self::reject( 'missing_validate' ); - } - - return match ( $field['type'] ) { - 'text' => self::is_text( $raw ) ? sanitize_text_field( (string) $raw ) : self::reject( 'invalid_value' ), - 'textarea' => self::is_text( $raw ) ? sanitize_textarea_field( (string) $raw ) : self::reject( 'invalid_value' ), - 'email' => self::sanitize_email( $raw ), - 'url' => self::sanitize_url( $raw ), - 'number' => self::sanitize_number( $raw ), - 'checkbox' => self::sanitize_checkbox( $raw ), - 'select', 'radio' => self::sanitize_choice( $field, $raw ), - 'multiselect', 'checkboxes' => self::sanitize_choices( $field, $raw ), - default => self::reject( 'unknown_type' ), - }; - } - /** * Returns the accepted values and the rejections of a submission, keyed by field id, for the defined, present and enabled fields only. * - * Callers merge the values onto the stored row with array_replace( $stored, $result['values'] ) and report the errors, so a submission writes no id outside the definition, and every stored id it leaves out, including those of rejected fields and of fields absent from the definition, keeps its value. + * Callers merge the values onto the stored row with array_replace( $stored, $result['values'] ) and report the errors, so a submission writes no id outside the definition, and every stored id it leaves out, including those of rejected fields and of fields absent from the definition, keeps its value. A field with validate hands its submission, alone, to the callable, which returns the value to store or a WP_Error and owns the field's allowed values, so no type sanitizer runs before or after it. Without validate, the field's type sanitizes the submission and checks its choices, and a rejection carries a code and a message that tells the user what to enter, which a host may replace by the code. Every WP_Error message is plain text, which the host escapes. * * @since 2.0.0 * @version 2.0.0 * * @param list $fields The fields. - * @param array $submitted The unslashed submission, keyed by field id. + * @param array $submitted The unslashed submission, keyed by field id, in which a cleared multi-value field is an empty string. * - * @return array{values: array, errors: array} + * @return array{values: array|null>, errors: array} */ #[\NoDiscard] public static function sanitize_fields( array $fields, array $submitted ): array { @@ -160,7 +121,30 @@ public static function sanitize_fields( array $fields, array $submitted ): array continue; } - $value = self::sanitize_value( $field, $submitted[ $id ] ); + $raw = $submitted[ $id ]; + if ( '' === $raw && \in_array( $field['type'], array( 'multiselect', 'checkboxes' ), true ) ) { + $raw = array(); + } + + if ( isset( $field['validate'] ) ) { + $value = $field['validate']( $raw ); + } elseif ( isset( $field['render'] ) ) { + $value = self::reject( 'missing_validate' ); + } else { + $value = match ( $field['type'] ) { + // The sanitizers encode a lone '<' that every output escapes again, so the stored text turns it back. + 'text' => self::is_text( $raw ) ? \str_replace( '<', '<', sanitize_text_field( (string) $raw ) ) : self::reject( 'invalid_value' ), + 'textarea' => self::is_text( $raw ) ? \str_replace( '<', '<', sanitize_textarea_field( (string) $raw ) ) : self::reject( 'invalid_value' ), + 'email' => self::sanitize_email( $raw ), + 'url' => self::sanitize_url( $raw ), + 'number' => self::sanitize_number( $raw ), + 'checkbox' => self::sanitize_checkbox( $raw ), + 'select', 'radio' => self::sanitize_choice( $field, $raw ), + 'multiselect', 'checkboxes' => self::sanitize_choices( $field, $raw ), + default => self::reject( 'unknown_type' ), + }; + } + if ( is_wp_error( $value ) ) { $result['errors'][ $id ] = $value; } else { @@ -230,11 +214,11 @@ public static function get_rejection_message( string $title, \WP_Error $error ): $message = $error->get_error_message(); if ( '' === $message ) { /* translators: %s: The field's title. */ - return \sprintf( __( '%s was not saved because its value is not valid, so it keeps its previous value.', 'wp-framework' ), $title ); + return \sprintf( __( '%s was not saved because its value is not valid, and keeps its previous value. The other changes were saved.', 'wp-framework' ), $title ); } /* translators: 1: The field's title. 2: What to enter instead. */ - return \sprintf( __( '%1$s was not saved, so it keeps its previous value. %2$s', 'wp-framework' ), $title, $message ); + return \sprintf( __( '%1$s was not saved and keeps its previous value. The other changes were saved. %2$s', 'wp-framework' ), $title, $message ); } /** @@ -420,7 +404,7 @@ protected static function render_checkbox( array $field, mixed $value, string $n 'value' => 'yes', 'class' => $field['class'] ?? null, 'style' => $field['css'] ?? null, - 'checked' => 'yes' === $value, + 'checked' => 'yes' === self::sanitize_checkbox( $value ), 'disabled' => $disabled, 'aria-describedby' => self::get_described_by( $help ), ); diff --git a/packages/shared/README.md b/packages/shared/README.md index b83cc2f1..5f778fb5 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -117,7 +117,7 @@ final readonly class Point extends AbstractValueObject { The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records. -The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too. +The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too. Releases follow SemVer on this contract from the first tag: a break of it ships as the next major, and internals change in any release. Plugins on different majors run side by side, because each ships its own prefixed copy. diff --git a/packages/shared/src/Exception/ExceptionInterface.php b/packages/shared/src/Exception/ExceptionInterface.php index 93b54704..82b882db 100644 --- a/packages/shared/src/Exception/ExceptionInterface.php +++ b/packages/shared/src/Exception/ExceptionInterface.php @@ -3,7 +3,7 @@ namespace DeepWebSolutions\Framework\Shared\Exception; /** - * Marks every exception the framework throws, so a caller can tell the framework's failures apart. + * Marks the exceptions the framework defines, so a caller can tell the framework's failures apart. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/shared/src/ValueObject/AbstractValueObject.php b/packages/shared/src/ValueObject/AbstractValueObject.php index c50b6992..44f8fce8 100644 --- a/packages/shared/src/ValueObject/AbstractValueObject.php +++ b/packages/shared/src/ValueObject/AbstractValueObject.php @@ -27,7 +27,7 @@ final public function equals( ValueObjectInterface $other ): bool { // region HELPERS /** - * Returns whether two property values are equal, comparing dates by instant. + * Returns whether two property values are equal, comparing dates by instant and value objects of one class through their own equals(). * * @since 2.0.0 * @version 2.0.0 @@ -39,7 +39,7 @@ final public function equals( ValueObjectInterface $other ): bool { */ private static function are_equal( mixed $value, mixed $other ): bool { return match ( true ) { - $value instanceof ValueObjectInterface => $other instanceof ValueObjectInterface && $value->equals( $other ), + $value instanceof ValueObjectInterface => $other instanceof ValueObjectInterface && $value::class === $other::class && $value->equals( $other ), $value instanceof \DateTimeInterface => $other instanceof \DateTimeInterface && $value->format( 'U.u' ) === $other->format( 'U.u' ), \is_array( $value ) => \is_array( $other ) && \array_keys( $value ) === \array_keys( $other ) && \array_all( $value, static fn ( mixed $item, int|string $key ): bool => self::are_equal( $item, $other[ $key ] ) ), default => $value === $other, diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md index 0dfc5e50..fc5b3dd7 100644 --- a/packages/woocommerce/README.md +++ b/packages/woocommerce/README.md @@ -182,7 +182,9 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records. -The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too. +The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too. + +`SettingsSection` follows WooCommerce's experimental settings API, so the methods it inherits, `get_settings_ui_page()`, `get_script_handles()` and `get_save_adapter()`, follow WooCommerce and may change in a minor release. Releases follow SemVer on this contract from the first tag: a break of it ships as the next major, and internals change in any release. Plugins on different majors run side by side, because each ships its own prefixed copy. diff --git a/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php b/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php index 154ddb71..a80db01f 100644 --- a/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php +++ b/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php @@ -18,8 +18,6 @@ * * @since 2.0.0 * @version 2.0.0 - * - * @phpstan-import-type Row from ObjectFormHostInterface */ final class OrderMetaBox implements ObjectFormHostInterface { // region FIELDS AND CONSTANTS diff --git a/packages/woocommerce/src/EditScreen/Stores/OrderMetaStore.php b/packages/woocommerce/src/EditScreen/Stores/OrderMetaStore.php index fdab0a40..75c35b2c 100644 --- a/packages/woocommerce/src/EditScreen/Stores/OrderMetaStore.php +++ b/packages/woocommerce/src/EditScreen/Stores/OrderMetaStore.php @@ -2,7 +2,9 @@ namespace DeepWebSolutions\Framework\WooCommerce\EditScreen\Stores; +use DeepWebSolutions\Framework\Core\Storage\StoredData; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectStoreInterface; +use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; \defined( 'ABSPATH' ) || exit; @@ -43,9 +45,12 @@ public function get_values( int $object_id, array $keys ): array { * * @since 2.0.0 * @version 2.0.0 + * + * @throws InvalidArgumentException Thrown when a value holds anything but scalars, null and arrays, before any value is stored. */ #[\Override] public function update_values( int $object_id, array $values ): void { + StoredData::check_values( $values ); $order = wc_get_order( $object_id ); if ( ! $order instanceof \WC_Abstract_Order ) { return; diff --git a/packages/woocommerce/src/Logging/WooCommerceLogger.php b/packages/woocommerce/src/Logging/WooCommerceLogger.php index 84662610..62488808 100644 --- a/packages/woocommerce/src/Logging/WooCommerceLogger.php +++ b/packages/woocommerce/src/Logging/WooCommerceLogger.php @@ -40,7 +40,7 @@ final class WooCommerceLogger extends AbstractLogger { * * @param string $source The source that files every record under the plugin in WooCommerce's log. * @param string|null $threshold Optional. The lowest level to log, which replaces the default of warning, or of every level under WP_DEBUG. Default is null. - * @param \WC_Logger_Interface|null $logger Optional. The WooCommerce logger to write to. Default is null, which resolves WooCommerce's logger on the first record, so construction never needs WooCommerce. + * @param \WC_Logger_Interface|null $logger Optional. The WooCommerce logger to write to. Default is null, which resolves WooCommerce's logger on the first record, so construction never needs WooCommerce. The error log fallback follows WooCommerce's logging settings, whichever logger is given. * * @throws PsrInvalidArgumentException Thrown when the threshold is not a PSR-3 level. */ diff --git a/packages/woocommerce/src/ProductData/ProductDataTab.php b/packages/woocommerce/src/ProductData/ProductDataTab.php index b3090aac..b3329031 100644 --- a/packages/woocommerce/src/ProductData/ProductDataTab.php +++ b/packages/woocommerce/src/ProductData/ProductDataTab.php @@ -15,7 +15,7 @@ * @since 2.0.0 * @version 2.0.0 * - * @phpstan-type ProductField array{type: string, id?: string, label?: string, description?: string, desc_tip?: bool, name?: string, default?: mixed, options?: array, placeholder?: string, class?: string, style?: string, wrapper_class?: string, custom_attributes?: array, disabled?: bool|list, validate?: callable(mixed): mixed, render?: callable(array, mixed, string, string): string} + * @phpstan-type ProductField array{type: string, id?: string, label?: string, description?: string, desc_tip?: bool, name?: string, default?: mixed, options?: array, placeholder?: string, class?: string, style?: string, wrapper_class?: string, custom_attributes?: array, disabled?: bool|list, validate?: callable(mixed): (scalar|array|\WP_Error|null), render?: callable(array, mixed, string, string): string} * @phpstan-type Group array{class?: list, fields: list} * @phpstan-type Tab array{label: string, priority?: int, class?: list, groups: list} */ @@ -222,7 +222,7 @@ protected function get_groups( array $tab, \WC_Product $product ): array { } /** - * Prints the field's row with its stored value, through WooCommerce's function for its type, or through Forms::render_control() for a render callable or a checkboxes group. + * Prints the field's row with its stored value, through WooCommerce's function for its type, or through Forms::render_control() for a render callable, radios or checkboxes. * * @since 2.0.0 * @version 2.0.0 @@ -245,10 +245,15 @@ protected static function render_field( array $field, \WC_Product $product ): vo $field['value'] = ProductMeta::get_value( $product, $key, $field['default'] ?? '', 'edit' ); $row_class = \trim( "form-field {$id}_field " . ( $field['wrapper_class'] ?? '' ) ); - if ( isset( $field['render'] ) ) { - $group = Forms::is_group( $field ); + if ( isset( $field['render'] ) || \in_array( $field['type'], array( 'radio', 'checkboxes' ), true ) ) { + // The row's legend names a group, so Forms draws its choices without one of their own and with the description as their help. + $control = isset( $field['render'] ) ? $field : array( + 'title' => '', + 'desc' => $field['description'] ?? '', + ) + $field; + $group = Forms::is_group( $field ); echo $group ? '
' . wp_kses_post( $field['label'] ) . '' : '

'; - echo Forms::render_control( $field, $field['value'], $key, $id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The render callable escapes its control. + echo Forms::render_control( $control, $field['value'], $key, $id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_control() escapes the control, and a render callable its own. echo $group ? '

' : '

'; return; } @@ -286,19 +291,6 @@ protected static function render_field( array $field, \WC_Product $product ): vo $field['custom_attributes'] = ( $field['custom_attributes'] ?? array() ) + array( 'multiple' => 'multiple' ); woocommerce_wp_select( $field ); break; - case 'radio': - woocommerce_wp_radio( $field ); - break; - case 'checkboxes': - // The row's legend names the group, so Forms draws the choices without one of its own and with the description as their help. - $group = array( - 'title' => '', - 'desc' => $field['description'] ?? '', - ) + $field; - echo '
' . wp_kses_post( $field['label'] ) . ''; - echo Forms::render_control( $group, $field['value'], $key, $id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_control() escapes the control. - echo '
'; - break; } } diff --git a/packages/woocommerce/src/Setting/Fields.php b/packages/woocommerce/src/Setting/Fields.php index 653123f0..5b0bebc4 100644 --- a/packages/woocommerce/src/Setting/Fields.php +++ b/packages/woocommerce/src/Setting/Fields.php @@ -2,7 +2,9 @@ namespace DeepWebSolutions\Framework\WooCommerce\Setting; +use DeepWebSolutions\Framework\Core\Storage\StoredData; use DeepWebSolutions\Framework\Settings\Forms; +use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; \defined( 'ABSPATH' ) || exit; @@ -39,10 +41,10 @@ final class Fields { * @since 2.0.0 * @version 2.0.0 * - * @param array $sections The helper's sections, keyed by id. + * @param list $section_ids The ids of the helper's sections. */ public function __construct( - protected array $sections + protected array $section_ids ) {} // endregion @@ -64,6 +66,24 @@ public function register_hooks( string $tab_id ): void { add_filter( 'woocommerce_admin_settings_sanitize_option', array( $this, 'sanitize_option' ), 10, 3 ); } + /** + * Throws unless WooCommerce keeps the tab or section id as it is, because it passes the ids through sanitize_title() and the fields are claimed by the ids it passes. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $id The tab or section id. + * + * @throws InvalidArgumentException Thrown when sanitize_title() changes the id. + * + * @return void + */ + public static function check_id( string $id ): void { + if ( sanitize_title( $id ) !== $id ) { + throw new InvalidArgumentException( "Id '$id' is not one that WooCommerce's sanitize_title() keeps as it is." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } + } + // endregion // region HOOKS @@ -80,7 +100,7 @@ public function register_hooks( string $tab_id ): void { * @return array */ public function claim( array $settings, string $section_id ): array { - if ( ! isset( $this->sections[ $section_id ] ) ) { + if ( ! \in_array( $section_id, $this->section_ids, true ) ) { return $settings; } @@ -101,7 +121,7 @@ public function claim( array $settings, string $section_id ): array { /** * Returns the value to store for an owned field's submission, or null, under which WooCommerce keeps the stored value, and any other option's value unchanged. * - * A disabled field and a field the form did not post are never saved. The submission goes through Forms::sanitize_fields(), where a field without validate, a render callable or the checkboxes type keeps WooCommerce's value, and a rejection becomes one of WooCommerce's settings errors. + * A disabled field and a field the form did not post are never saved. The submission goes through Forms::sanitize_fields(), where a field without validate, a render callable or the checkboxes type keeps WooCommerce's value when it holds only scalars, null and arrays, and a rejection becomes one of WooCommerce's settings errors. * * @since 2.0.0 * @version 2.0.0 @@ -125,7 +145,7 @@ public function sanitize_option( mixed $value, mixed $option, mixed $raw ): mixe default => null, }; if ( ! isset( $field['validate'] ) && ! self::is_drawn_by_forms( $field ) ) { - $field['validate'] = static fn (): mixed => $value; + $field['validate'] = static fn (): mixed => StoredData::is_storable_data( $value ) ? $value : null; } $id = $field['id'] ?? ''; diff --git a/packages/woocommerce/src/Setting/SettingsSection.php b/packages/woocommerce/src/Setting/SettingsSection.php index f9c2df6d..7e997a50 100644 --- a/packages/woocommerce/src/Setting/SettingsSection.php +++ b/packages/woocommerce/src/Setting/SettingsSection.php @@ -56,7 +56,7 @@ final class SettingsSection extends WooCommerceSettingsSection { * @param Section $section The id of the tab the section belongs to, and the section's label. * @param SectionFieldsDefinitionInterface $definition The definition of the section's fields, which the section asks for once, when WooCommerce first asks for them. * - * @throws InvalidArgumentException Thrown when the id is not a valid key. + * @throws InvalidArgumentException Thrown when the id is not a valid key or one WooCommerce keeps as it is. */ public function __construct( protected string $id, @@ -64,7 +64,8 @@ public function __construct( protected SectionFieldsDefinitionInterface $definition ) { Forms::check_helper_id( $id ); - $this->owner = new Fields( array( $id => true ) ); + Fields::check_id( $id ); + $this->owner = new Fields( array( $id ) ); } // endregion diff --git a/packages/woocommerce/src/Setting/SettingsTab.php b/packages/woocommerce/src/Setting/SettingsTab.php index 46c71a27..a5c2d20c 100644 --- a/packages/woocommerce/src/Setting/SettingsTab.php +++ b/packages/woocommerce/src/Setting/SettingsTab.php @@ -65,7 +65,7 @@ final class SettingsTab extends \WC_Settings_Page { * @param Tab $tab The tab's label and the labels of its sections by id. * @param SectionFieldsDefinitionInterface $definition The definition of the sections' fields, which the tab asks for once per section, when WooCommerce first asks for them. * - * @throws InvalidArgumentException Thrown when the id is not a valid key. + * @throws InvalidArgumentException Thrown when the id is not a valid key, or it or a section key is not one WooCommerce keeps as it is. */ public function __construct( string $id, @@ -73,13 +73,17 @@ public function __construct( protected SectionFieldsDefinitionInterface $definition ) { Forms::check_helper_id( $id ); + $section_ids = \array_map( 'strval', \array_keys( $tab['sections'] ) ); + foreach ( array( $id, ...$section_ids ) as $checked ) { + Fields::check_id( $checked ); + } $this->id = $id; $this->label = $tab['label']; $this->sections = $tab['sections']; parent::__construct(); - $this->owner = new Fields( $this->sections ); + $this->owner = new Fields( $section_ids ); $this->owner->register_hooks( $id ); } diff --git a/tests/Fixtures/consumer-a/composer.lock b/tests/Fixtures/consumer-a/composer.lock index b72bf6df..30f3b0bc 100644 --- a/tests/Fixtures/consumer-a/composer.lock +++ b/tests/Fixtures/consumer-a/composer.lock @@ -13,12 +13,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wordpress-configs.git", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd" + "reference": "b107001cce9f2960495d9d50481c8540d228f60c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd", + "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c", + "reference": "b107001cce9f2960495d9d50481c8540d228f60c", "shasum": "" }, "require": { @@ -146,7 +146,7 @@ "source": "https://github.com/ahegyes/wordpress-configs/tree/trunk", "issues": "https://github.com/ahegyes/wordpress-configs/issues" }, - "time": "2026-11-07T10:23:14+00:00" + "time": "2026-11-09T15:51:41+00:00" }, { "name": "ahegyes/wp-framework-bootstrap", @@ -154,7 +154,7 @@ "dist": { "type": "path", "url": "../../../packages/bootstrap", - "reference": "7e2974207d40704338c7e945fd1057dd6f2bb615" + "reference": "7b8b66dbf1796b00158204faf14698cc5f875a46" }, "require": { "php": ">=7.4" @@ -182,7 +182,7 @@ "dist": { "type": "path", "url": "../../../packages/core", - "reference": "825003655db71a41d3a6b41621d06b1bca993d63" + "reference": "d44c1620202b9c0bcfba1cd6afd8791327a3523e" }, "require": { "ahegyes/wp-framework-bootstrap": "^2.0", @@ -223,7 +223,7 @@ "dist": { "type": "path", "url": "../../../packages/settings", - "reference": "adf1c74fb62e86f191024a94a2fe9ed831ee010a" + "reference": "91a0b9646ff54feb4dd17a2cd31db78a35aee802" }, "require": { "ahegyes/wp-framework-core": "^2.0", @@ -258,7 +258,7 @@ "dist": { "type": "path", "url": "../../../packages/shared", - "reference": "36f6c7a8ce0f9669d025e8935b6310ae2131537e" + "reference": "0d30eb24a6ff8c14fd8ae445ee4fc7efb8cd2dbf" }, "require": { "php": ">=8.5" @@ -290,7 +290,7 @@ "dist": { "type": "path", "url": "../../../packages/woocommerce", - "reference": "598819f5082d70ecf181172606d6dd2041e75fdb" + "reference": "79d6aea22490f658088fcea374c11a136074961f" }, "require": { "ahegyes/wp-framework-settings": "^2.0", @@ -301,14 +301,14 @@ "branch-alias": { "dev-trunk": "2.x-dev" }, - "scoping-stubs": [ - "php-stubs/woocommerce-stubs" - ], - "scoping-text-domain": "wp-framework", "requires-wp": "7.1", "requires-plugins": [ "woocommerce" - ] + ], + "scoping-stubs": [ + "php-stubs/woocommerce-stubs" + ], + "scoping-text-domain": "wp-framework" }, "autoload": { "psr-4": { diff --git a/tests/Fixtures/consumer-a/uninstall.php b/tests/Fixtures/consumer-a/uninstall.php index b482f573..6721108b 100644 --- a/tests/Fixtures/consumer-a/uninstall.php +++ b/tests/Fixtures/consumer-a/uninstall.php @@ -41,14 +41,23 @@ return; } -Uninstaller::run( - $acme_ca_file, - $acme_ca_footprint, - array( - 'consent' => array( - 'option' => Plugin::PREFIX . '_settings', - 'key' => 'delete_data', - ), - 'meta_types' => $acme_ca_meta_types, - ) -); +try { + Uninstaller::run( + $acme_ca_file, + $acme_ca_footprint, + array( + 'consent' => array( + 'option' => Plugin::PREFIX . '_settings', + 'key' => 'delete_data', + ), + 'meta_types' => $acme_ca_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/tests/Fixtures/consumer-b/composer.lock b/tests/Fixtures/consumer-b/composer.lock index ca6789f7..c6836c7f 100644 --- a/tests/Fixtures/consumer-b/composer.lock +++ b/tests/Fixtures/consumer-b/composer.lock @@ -13,12 +13,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wordpress-configs.git", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd" + "reference": "b107001cce9f2960495d9d50481c8540d228f60c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd", + "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c", + "reference": "b107001cce9f2960495d9d50481c8540d228f60c", "shasum": "" }, "require": { @@ -146,7 +146,7 @@ "source": "https://github.com/ahegyes/wordpress-configs/tree/trunk", "issues": "https://github.com/ahegyes/wordpress-configs/issues" }, - "time": "2026-11-07T10:23:14+00:00" + "time": "2026-11-09T15:51:41+00:00" }, { "name": "ahegyes/wp-framework-bootstrap", @@ -154,7 +154,7 @@ "dist": { "type": "path", "url": "../../../packages/bootstrap", - "reference": "7e2974207d40704338c7e945fd1057dd6f2bb615" + "reference": "7b8b66dbf1796b00158204faf14698cc5f875a46" }, "require": { "php": ">=7.4" @@ -182,7 +182,7 @@ "dist": { "type": "path", "url": "../../../packages/core", - "reference": "825003655db71a41d3a6b41621d06b1bca993d63" + "reference": "d44c1620202b9c0bcfba1cd6afd8791327a3523e" }, "require": { "ahegyes/wp-framework-bootstrap": "^2.0", @@ -223,7 +223,7 @@ "dist": { "type": "path", "url": "../../../packages/settings", - "reference": "adf1c74fb62e86f191024a94a2fe9ed831ee010a" + "reference": "91a0b9646ff54feb4dd17a2cd31db78a35aee802" }, "require": { "ahegyes/wp-framework-core": "^2.0", @@ -258,7 +258,7 @@ "dist": { "type": "path", "url": "../../../packages/shared", - "reference": "36f6c7a8ce0f9669d025e8935b6310ae2131537e" + "reference": "0d30eb24a6ff8c14fd8ae445ee4fc7efb8cd2dbf" }, "require": { "php": ">=8.5" diff --git a/tests/Fixtures/personas/bootstrap/composer.lock b/tests/Fixtures/personas/bootstrap/composer.lock index 0cc920b1..e082697d 100644 --- a/tests/Fixtures/personas/bootstrap/composer.lock +++ b/tests/Fixtures/personas/bootstrap/composer.lock @@ -13,12 +13,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wordpress-configs.git", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd" + "reference": "b107001cce9f2960495d9d50481c8540d228f60c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd", + "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c", + "reference": "b107001cce9f2960495d9d50481c8540d228f60c", "shasum": "" }, "require": { @@ -146,7 +146,7 @@ "source": "https://github.com/ahegyes/wordpress-configs/tree/trunk", "issues": "https://github.com/ahegyes/wordpress-configs/issues" }, - "time": "2026-11-07T10:23:14+00:00" + "time": "2026-11-09T15:51:41+00:00" }, { "name": "ahegyes/wp-framework-bootstrap", @@ -154,7 +154,7 @@ "dist": { "type": "path", "url": "../../../../packages/bootstrap", - "reference": "7e2974207d40704338c7e945fd1057dd6f2bb615" + "reference": "7b8b66dbf1796b00158204faf14698cc5f875a46" }, "require": { "php": ">=7.4" diff --git a/tests/Fixtures/personas/bootstrap/functions.php b/tests/Fixtures/personas/bootstrap/functions.php index f40ed666..4b456b25 100644 --- a/tests/Fixtures/personas/bootstrap/functions.php +++ b/tests/Fixtures/personas/bootstrap/functions.php @@ -8,6 +8,8 @@ * @package Acme\PersonaBootstrap */ +use Acme\PersonaBootstrap\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements; + defined( 'ABSPATH' ) || exit; add_action( 'admin_notices', 'acme_pb_render_notice' ); @@ -21,9 +23,5 @@ * @return void */ function acme_pb_render_notice(): void { - if ( ! current_user_can( 'activate_plugins' ) ) { - return; - } - - printf( '

%s

', esc_html__( 'Acme Persona Bootstrap is running.', 'acme-persona-bootstrap' ) ); + Requirements::print_admin_notice( esc_html__( 'Acme Persona Bootstrap is running.', 'acme-persona-bootstrap' ), array( 'type' => 'info' ) ); } diff --git a/tests/Fixtures/personas/core/composer.lock b/tests/Fixtures/personas/core/composer.lock index fcabd2bf..a7ac4ca9 100644 --- a/tests/Fixtures/personas/core/composer.lock +++ b/tests/Fixtures/personas/core/composer.lock @@ -13,12 +13,12 @@ "source": { "type": "git", "url": "git@github.com:ahegyes/wordpress-configs.git", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd" + "reference": "b107001cce9f2960495d9d50481c8540d228f60c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd", + "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c", + "reference": "b107001cce9f2960495d9d50481c8540d228f60c", "shasum": "" }, "require": { @@ -142,7 +142,7 @@ } ], "description": "Shared tooling that lets WordPress projects be checked, scoped and released the same way.", - "time": "2026-11-07T10:23:14+00:00" + "time": "2026-11-09T15:51:41+00:00" }, { "name": "ahegyes/wp-framework-bootstrap", @@ -150,7 +150,7 @@ "dist": { "type": "path", "url": "../../../../packages/bootstrap", - "reference": "7e2974207d40704338c7e945fd1057dd6f2bb615" + "reference": "7b8b66dbf1796b00158204faf14698cc5f875a46" }, "require": { "php": ">=7.4" @@ -178,7 +178,7 @@ "dist": { "type": "path", "url": "../../../../packages/core", - "reference": "825003655db71a41d3a6b41621d06b1bca993d63" + "reference": "d44c1620202b9c0bcfba1cd6afd8791327a3523e" }, "require": { "ahegyes/wp-framework-bootstrap": "^2.0", @@ -219,7 +219,7 @@ "dist": { "type": "path", "url": "../../../../packages/shared", - "reference": "36f6c7a8ce0f9669d025e8935b6310ae2131537e" + "reference": "0d30eb24a6ff8c14fd8ae445ee4fc7efb8cd2dbf" }, "require": { "php": ">=8.5" diff --git a/tests/Fixtures/personas/settings/composer.lock b/tests/Fixtures/personas/settings/composer.lock index e74a9282..835d7dcc 100644 --- a/tests/Fixtures/personas/settings/composer.lock +++ b/tests/Fixtures/personas/settings/composer.lock @@ -13,12 +13,12 @@ "source": { "type": "git", "url": "git@github.com:ahegyes/wordpress-configs.git", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd" + "reference": "b107001cce9f2960495d9d50481c8540d228f60c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd", + "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c", + "reference": "b107001cce9f2960495d9d50481c8540d228f60c", "shasum": "" }, "require": { @@ -142,7 +142,7 @@ } ], "description": "Shared tooling that lets WordPress projects be checked, scoped and released the same way.", - "time": "2026-11-07T10:23:14+00:00" + "time": "2026-11-09T15:51:41+00:00" }, { "name": "ahegyes/wp-framework-bootstrap", @@ -150,7 +150,7 @@ "dist": { "type": "path", "url": "../../../../packages/bootstrap", - "reference": "7e2974207d40704338c7e945fd1057dd6f2bb615" + "reference": "7b8b66dbf1796b00158204faf14698cc5f875a46" }, "require": { "php": ">=7.4" @@ -178,7 +178,7 @@ "dist": { "type": "path", "url": "../../../../packages/core", - "reference": "825003655db71a41d3a6b41621d06b1bca993d63" + "reference": "d44c1620202b9c0bcfba1cd6afd8791327a3523e" }, "require": { "ahegyes/wp-framework-bootstrap": "^2.0", @@ -219,7 +219,7 @@ "dist": { "type": "path", "url": "../../../../packages/settings", - "reference": "adf1c74fb62e86f191024a94a2fe9ed831ee010a" + "reference": "91a0b9646ff54feb4dd17a2cd31db78a35aee802" }, "require": { "ahegyes/wp-framework-core": "^2.0", @@ -254,7 +254,7 @@ "dist": { "type": "path", "url": "../../../../packages/shared", - "reference": "36f6c7a8ce0f9669d025e8935b6310ae2131537e" + "reference": "0d30eb24a6ff8c14fd8ae445ee4fc7efb8cd2dbf" }, "require": { "php": ">=8.5" diff --git a/tests/Fixtures/personas/shared/composer.lock b/tests/Fixtures/personas/shared/composer.lock index d47c7c3c..61b049f7 100644 --- a/tests/Fixtures/personas/shared/composer.lock +++ b/tests/Fixtures/personas/shared/composer.lock @@ -12,13 +12,13 @@ "version": "dev-trunk", "source": { "type": "git", - "url": "git@github.com:ahegyes/wordpress-configs.git", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd" + "url": "https://github.com/ahegyes/wordpress-configs.git", + "reference": "b107001cce9f2960495d9d50481c8540d228f60c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd", + "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c", + "reference": "b107001cce9f2960495d9d50481c8540d228f60c", "shasum": "" }, "require": { @@ -142,7 +142,11 @@ } ], "description": "Shared tooling that lets WordPress projects be checked, scoped and released the same way.", - "time": "2026-11-07T10:23:14+00:00" + "support": { + "source": "https://github.com/ahegyes/wordpress-configs/tree/trunk", + "issues": "https://github.com/ahegyes/wordpress-configs/issues" + }, + "time": "2026-11-09T15:51:41+00:00" }, { "name": "ahegyes/wp-framework-shared", @@ -150,7 +154,7 @@ "dist": { "type": "path", "url": "../../../../packages/shared", - "reference": "36f6c7a8ce0f9669d025e8935b6310ae2131537e" + "reference": "0d30eb24a6ff8c14fd8ae445ee4fc7efb8cd2dbf" }, "require": { "php": ">=8.5" diff --git a/tests/Fixtures/personas/woocommerce/composer.lock b/tests/Fixtures/personas/woocommerce/composer.lock index baf2de00..1e76be55 100644 --- a/tests/Fixtures/personas/woocommerce/composer.lock +++ b/tests/Fixtures/personas/woocommerce/composer.lock @@ -13,12 +13,12 @@ "source": { "type": "git", "url": "git@github.com:ahegyes/wordpress-configs.git", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd" + "reference": "b107001cce9f2960495d9d50481c8540d228f60c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd", - "reference": "c944e73ab2656633397e7736ea104bbe72726ffd", + "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c", + "reference": "b107001cce9f2960495d9d50481c8540d228f60c", "shasum": "" }, "require": { @@ -142,7 +142,7 @@ } ], "description": "Shared tooling that lets WordPress projects be checked, scoped and released the same way.", - "time": "2026-11-07T10:23:14+00:00" + "time": "2026-11-09T15:51:41+00:00" }, { "name": "ahegyes/wp-framework-bootstrap", @@ -150,7 +150,7 @@ "dist": { "type": "path", "url": "../../../../packages/bootstrap", - "reference": "7e2974207d40704338c7e945fd1057dd6f2bb615" + "reference": "7b8b66dbf1796b00158204faf14698cc5f875a46" }, "require": { "php": ">=7.4" @@ -178,7 +178,7 @@ "dist": { "type": "path", "url": "../../../../packages/core", - "reference": "825003655db71a41d3a6b41621d06b1bca993d63" + "reference": "d44c1620202b9c0bcfba1cd6afd8791327a3523e" }, "require": { "ahegyes/wp-framework-bootstrap": "^2.0", @@ -219,7 +219,7 @@ "dist": { "type": "path", "url": "../../../../packages/settings", - "reference": "adf1c74fb62e86f191024a94a2fe9ed831ee010a" + "reference": "91a0b9646ff54feb4dd17a2cd31db78a35aee802" }, "require": { "ahegyes/wp-framework-core": "^2.0", @@ -254,7 +254,7 @@ "dist": { "type": "path", "url": "../../../../packages/shared", - "reference": "36f6c7a8ce0f9669d025e8935b6310ae2131537e" + "reference": "0d30eb24a6ff8c14fd8ae445ee4fc7efb8cd2dbf" }, "require": { "php": ">=8.5" @@ -286,7 +286,7 @@ "dist": { "type": "path", "url": "../../../../packages/woocommerce", - "reference": "598819f5082d70ecf181172606d6dd2041e75fdb" + "reference": "79d6aea22490f658088fcea374c11a136074961f" }, "require": { "ahegyes/wp-framework-settings": "^2.0", @@ -297,14 +297,14 @@ "branch-alias": { "dev-trunk": "2.x-dev" }, - "scoping-stubs": [ - "php-stubs/woocommerce-stubs" - ], - "scoping-text-domain": "wp-framework", "requires-wp": "7.1", "requires-plugins": [ "woocommerce" - ] + ], + "scoping-stubs": [ + "php-stubs/woocommerce-stubs" + ], + "scoping-text-domain": "wp-framework" }, "autoload": { "psr-4": { diff --git a/tests/Integration/Bootstrap/RequirementsTest.php b/tests/Integration/Bootstrap/RequirementsTest.php index 1df2f68e..dd9ea7c7 100644 --- a/tests/Integration/Bootstrap/RequirementsTest.php +++ b/tests/Integration/Bootstrap/RequirementsTest.php @@ -113,7 +113,6 @@ public function test_a_refused_plugin_outside_the_plugins_directory_prints_nothi $plugin_file = $this->write_plugin( untrailingslashit( get_temp_dir() ), array( 'Requires PHP' => '99' ) ); self::assertFalse( Requirements::check_floors( $plugin_file ) ); - // Core reads the headers from the plugins directory, so it warns about the missing file and reports the requirements as met. $warnings = array(); \set_error_handler( static function ( int $level, string $message ) use ( &$warnings ): bool { @@ -130,8 +129,7 @@ static function ( int $level, string $message ) use ( &$warnings ): bool { } self::assertSame( '', $html ); - self::assertCount( 1, $warnings ); - self::assertStringContainsString( 'Failed to open stream', $warnings[0] ); + self::assertSame( array(), $warnings ); } public function test_an_error_prints_its_message_without_a_paragraph_wrap(): void { diff --git a/tests/Integration/Bootstrap/UpdaterTest.php b/tests/Integration/Bootstrap/UpdaterTest.php index cedfbe92..2c40d4a3 100644 --- a/tests/Integration/Bootstrap/UpdaterTest.php +++ b/tests/Integration/Bootstrap/UpdaterTest.php @@ -20,6 +20,8 @@ final class UpdaterTest extends TestCase { protected array $requests = array(); + protected array $accepts = array(); + #[\Override] public static function setUpBeforeClass(): void { require_once \dirname( __DIR__, 3 ) . '/packages/bootstrap/load.php'; @@ -64,6 +66,7 @@ static function ( self $test ): void { }, ), 'tag-not-a-version' => array( static fn ( self $test ) => $test->stage_latest( $test->release( 'preview' ) ) ), + 'unflagged-prerelease' => array( static fn ( self $test ) => $test->stage_latest( $test->release( 'v2.1.0-beta.1' ) ) ), 'requirement-not-version' => array( static fn ( self $test ) => $test->stage_latest( $test->release( 'v2.0.0' ), '', array( 'Requires PHP' => '8.1' ) ) ), ); } @@ -74,7 +77,8 @@ public static function prerelease_lists(): array { 'stable-successor' => array( '1.2.0-beta.2', array( 'v1.2.0', 'v1.2.0-beta.2' ), '1.2.0' ), 'word-tag-listed' => array( '1.1.0-beta.1', array( 'preview', 'v1.2.0-beta.1' ), '1.2.0-beta.1' ), 'unordered-label-listed' => array( '2.1.0-beta.1', array( 'v2.1.0', 'v2.1.0-pre.1' ), '2.1.0' ), - 'uppercase-label' => array( '2.1.0-beta.1', array( 'v2.1.0-RC1' ), '2.1.0-RC1' ), + 'uppercase-label-listed' => array( '2.0.0-beta.1', array( 'v2.1.0-RC1', 'v2.0.0' ), '2.0.0' ), + 'mixed-case-label-listed' => array( '2.0.0-beta.1', array( 'v2.1.0-Beta.2', 'v2.0.0' ), '2.0.0' ), ); } @@ -202,6 +206,35 @@ public function test_a_cached_record_without_a_package_is_fetched_again(): void self::assertSame( $this->asset_url( 'v2.0.0' ), get_transient( "{$this->slug}_github_release_stable" )['package'] ?? null ); } + public function test_a_cached_record_holding_a_field_of_another_type_is_fetched_again(): void { + Updater::register( $this->write_plugin( $this->slug ) ); + set_transient( + "{$this->slug}_github_release_stable", + array( + 'version' => 9, + 'package' => $this->asset_url( 'v9.0.0' ), + ), + HOUR_IN_SECONDS + ); + $this->stage_latest( $this->release( 'v2.0.0' ) ); + + $offer = $this->check_for_updates()->response[ $this->basename() ] ?? null; + + self::assertSame( '2.0.0', $offer->new_version ?? null ); + self::assertCount( 2, $this->requests ); + } + + public function test_a_requirement_past_the_first_8_kb_of_the_entry_file_reads_as_none(): void { + Updater::register( $this->write_plugin( $this->slug ) ); + $this->stage_latest( $this->release( 'v2.0.0' ) ); + $this->stage( $this->raw_url( 'v2.0.0' ), 200, "check_for_updates()->response[ $this->basename() ] ?? null; + + self::assertIsObject( $offer ); + self::assertSame( '', $offer->requires_php ); + } + public function test_another_plugins_check_passes_through_without_a_request(): void { Updater::register( $this->write_plugin( $this->slug ) ); $this->write_plugin( "$this->slug-other" ); @@ -247,7 +280,7 @@ public function test_two_plugins_never_answer_for_each_other(): void { #[DataProvider( 'channel_switches' )] public function test_each_channel_caches_separately_so_a_switch_never_serves_the_other_channels_release( string $first_version, string $first_path, string $second_version, string $second_path ): void { Updater::register( $this->write_plugin( $this->slug, array( 'Version' => $first_version ) ) ); - $this->stage_release_at( $first_path, $this->release( 'v3.0.0-beta.1' ) ); + $this->stage_release_at( $first_path, $this->release( 'v3.0.0' ) ); $this->check_for_updates(); $this->write_plugin( $this->slug, array( 'Version' => $second_version ) ); @@ -260,12 +293,25 @@ public function test_each_channel_caches_separately_so_a_switch_never_serves_the } public function test_details_for_its_own_slug_show_the_cached_release_without_another_request(): void { - Updater::register( $this->write_plugin( $this->slug ) ); + Updater::register( + $this->write_plugin( + $this->slug, + array( + 'Author' => 'Acme', + 'Plugin URI' => 'https://example.org/acme-updater', + ) + ) + ); $requirements = array( 'Requires at least' => '6.0', 'Requires PHP' => '8.1', + 'Tested up to' => '7.1', + ); + $release = array( + 'body_html' => '

Fixes the widget.

', + 'published_at' => '2026-11-02T09:30:00Z', ); - $this->stage_latest( array( 'body' => "Fixes the widget.\nAdds a setting." ) + $this->release( 'v2.0.0' ), '', $requirements ); + $this->stage_latest( $release + $this->release( 'v2.0.0' ), '', $requirements ); $this->check_for_updates(); $details = plugins_api( 'plugin_information', array( 'slug' => $this->slug ) ); @@ -277,9 +323,15 @@ public function test_details_for_its_own_slug_show_the_cached_release_without_an self::assertSame( $this->asset_url( 'v2.0.0' ), $details->download_link ); self::assertSame( '6.0', $details->requires ); self::assertSame( '8.1', $details->requires_php ); + self::assertSame( '7.1', $details->tested ); + self::assertSame( 'Acme', $details->author ); + self::assertSame( 'https://example.org/acme-updater', $details->homepage ); + self::assertSame( '2026-11-02T09:30:00Z', $details->last_updated ); self::assertTrue( $details->external ); - self::assertSame( "Fixes <b>the</b> widget.
\nAdds a setting.", $details->sections['changelog'] ); + self::assertSame( '

Fixes the widget.

', $details->sections['changelog'] ); self::assertCount( 2, $this->requests ); + self::assertSame( 'application/vnd.github.html+json', $this->accepts[ $this->api_url( 'releases/latest' ) ] ?? null ); + self::assertSame( array( 'version', 'package', 'url', 'body', 'last_updated', 'requires', 'requires_php', 'tested' ), \array_keys( get_transient( "{$this->slug}_github_release_stable" ) ) ); } public function test_details_for_its_own_slug_stay_local_without_a_release(): void { @@ -355,6 +407,7 @@ public function test_an_offer_carries_the_release_requirements_and_a_php_floor_n $requirements = array( 'Requires at least' => '5.8', 'Requires PHP' => '99', + 'Tested up to' => '7.1', ); $this->stage_latest( $this->release( 'v2.0.0' ), '', $requirements ); @@ -363,6 +416,16 @@ public function test_an_offer_carries_the_release_requirements_and_a_php_floor_n self::assertIsObject( $offer ); self::assertSame( '5.8', $offer->requires ); self::assertSame( '99', $offer->requires_php ); + self::assertSame( '7.1', $offer->tested ); + } + + public function test_a_tested_version_that_is_not_a_version_reaches_the_offer_as_it_is(): void { + Updater::register( $this->write_plugin( $this->slug ) ); + $this->stage_latest( $this->release( 'v2.0.0' ), '', array( 'Tested up to' => '7.1 beta' ) ); + + $offer = $this->check_for_updates()->response[ $this->basename() ] ?? null; + + self::assertSame( '7.1 beta', $offer->tested ?? null ); } public function test_an_offer_needing_a_newer_wordpress_is_withheld(): void { @@ -457,7 +520,8 @@ public function answer_request( mixed $preempt, array $args, string $url ): mixe } if ( \in_array( wp_parse_url( $url, \PHP_URL_HOST ), array( 'api.github.com', 'raw.githubusercontent.com' ), true ) ) { - $this->requests[] = $url; + $this->requests[] = $url; + $this->accepts[ $url ] = $args['headers']['Accept'] ?? null; } return $this->responses[ $url ] ?? new \WP_Error( 'http_request_failed', "Nothing answers $url." ); diff --git a/tests/Integration/ConsumerA/ConsumerABootTest.php b/tests/Integration/ConsumerA/ConsumerABootTest.php index b2bf6fca..fcc3d4a0 100644 --- a/tests/Integration/ConsumerA/ConsumerABootTest.php +++ b/tests/Integration/ConsumerA/ConsumerABootTest.php @@ -15,7 +15,7 @@ final class ConsumerABootTest extends TestCase { protected const string SCOPED = 'Acme\\ConsumerA\\Scoped\\DeepWebSolutions\\Framework\\'; - public function test_the_plugin_boots_with_its_scheduler_and_admin_notices_as_kernel_roots(): void { + public function test_the_plugin_boots_and_its_components_register_their_hooks(): void { $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' ); $request = $this->consumer_request( 'GET', 'index.php', $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) ); @@ -23,7 +23,6 @@ public function test_the_plugin_boots_with_its_scheduler_and_admin_notices_as_ke self::assertStringContainsString( 'id="wpfooter"', $request['body'] ); self::assertSame( 'booted', $request['records']['consumer_a']['status'] ); self::assertSame( 1, $request['records']['consumer_a']['boot_succeeded'] ); - self::assertSame( array( self::SCOPED . 'Core\\Scheduling\\Scheduler', self::SCOPED . 'Core\\AdminNotice\\AdminNotices', 'Acme\\ConsumerA\\ProductData', 'Acme\\ConsumerA\\Exports' ), $request['records']['consumer_a']['resolved'] ); self::assertTrue( $request['records']['export_hooked'] ); self::assertFalse( $request['records']['unloaded_include'] ); } diff --git a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php index ed8c041d..50f89cd5 100644 --- a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php +++ b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php @@ -50,6 +50,20 @@ public function test_a_consenting_web_uninstall_removes_the_footprint_and_the_fi self::assertSame( '', get_user_meta( $this->user_id, 'acme_ca_network_hidden_notices', true ) ); } + public function test_a_web_uninstall_whose_engine_fails_shows_its_message_and_keeps_the_data_and_the_files(): void { + global $wpdb; + + $this->seed_footprint(); + $plugin = $this->install_consumer( 'consumer-a', self::DIR ); + $request = $this->uninstall_on_the_web( $plugin, array(), "DELETE FROM `$wpdb->options` WHERE `option_name` = 'acme_ca_revision'" ); + + self::assertSame( 500, $request['died']['status'] ?? null ); + self::assertStringStartsWith( 'Uninstall could not remove everything: option acme_ca_revision: ', $request['died']['message'] ?? '' ); + self::assertFileExists( WP_PLUGIN_DIR . '/' . self::DIR . '/acme-consumer-a.php' ); + self::assertSame( array( 'delete_data' => 'yes' ), get_option( 'acme_ca_settings' ) ); + self::assertSame( '1', get_option( 'acme_ca_revision' ) ); + } + #[DataProvider( 'engines_that_cannot_run' )] public function test_a_web_uninstall_that_cannot_run_the_engine_keeps_the_data_logs_once_and_deletes_the_files( array $remove, array $headers ): void { $this->seed_footprint(); @@ -112,7 +126,7 @@ protected function seed_footprint(): void { $this->product_id = $product->save(); } - protected function uninstall_on_the_web( string $plugin, array $missing ): array { + protected function uninstall_on_the_web( string $plugin, array $missing, string $failing_query = '' ): array { $this->log = (string) \tempnam( \sys_get_temp_dir(), 'acme-ca-log-' ); return $this->consumer_request( @@ -120,9 +134,10 @@ protected function uninstall_on_the_web( string $plugin, array $missing ): array 'index.php', $this->make_user( 'acme_ca_admin', 'administrator' ), array( - 'uninstall' => $plugin, - 'missing' => $missing, - 'log' => $this->log, + 'uninstall' => $plugin, + 'missing' => $missing, + 'log' => $this->log, + 'failing_query' => $failing_query, ) ); } diff --git a/tests/Integration/ConsumerB/ConsumerBBootTest.php b/tests/Integration/ConsumerB/ConsumerBBootTest.php index 8f687c5a..7705c617 100644 --- a/tests/Integration/ConsumerB/ConsumerBBootTest.php +++ b/tests/Integration/ConsumerB/ConsumerBBootTest.php @@ -21,7 +21,6 @@ public function test_the_plugin_boots_without_woocommerce_and_its_settings_tab_r self::assertNull( $request['died'] ); self::assertFalse( $request['records']['woocommerce_present'] ); self::assertSame( 'booted', $request['records']['consumer_b']['status'] ); - self::assertSame( array( 'Acme\\ConsumerB\\Scoped\\DeepWebSolutions\\Framework\\Core\\AdminNotice\\AdminNotices', 'Acme\\ConsumerB\\Settings' ), $request['records']['consumer_b']['resolved'] ); self::assertStringContainsString( 'name="acme_cb_settings[greeting]"', $request['body'] ); } diff --git a/tests/Integration/Consumers/ConsumersCoexistenceTest.php b/tests/Integration/Consumers/ConsumersCoexistenceTest.php index c4ab5366..4976dc53 100644 --- a/tests/Integration/Consumers/ConsumersCoexistenceTest.php +++ b/tests/Integration/Consumers/ConsumersCoexistenceTest.php @@ -13,9 +13,9 @@ final class ConsumersCoexistenceTest extends TestCase { use CreatesUsersTrait; use InstallsConsumersTrait; - protected const array ROOTS = array( - 'consumer_a' => array( 'Acme\\ConsumerA\\Scoped\\DeepWebSolutions\\Framework\\Core\\Scheduling\\Scheduler', 'Acme\\ConsumerA\\Scoped\\DeepWebSolutions\\Framework\\Core\\AdminNotice\\AdminNotices', 'Acme\\ConsumerA\\ProductData', 'Acme\\ConsumerA\\Exports' ), - 'consumer_b' => array( 'Acme\\ConsumerB\\Scoped\\DeepWebSolutions\\Framework\\Core\\AdminNotice\\AdminNotices', 'Acme\\ConsumerB\\Settings' ), + protected const array SKIPPED = array( + 'consumer_a' => array( 'Acme\\ConsumerA\\Reports\\Reports', 'Acme\\ConsumerA\\FaultInjector' ), + 'consumer_b' => array(), ); public static function activation_orders(): array { @@ -59,10 +59,10 @@ public function test_both_plugins_boot_with_their_own_components_in_either_load_ self::assertNull( $request['died'] ); self::assertStringContainsString( 'id="wpfooter"', $request['body'] ); - foreach ( self::ROOTS as $consumer => $roots ) { + foreach ( self::SKIPPED as $consumer => $skipped ) { self::assertSame( 'booted', $request['records'][ $consumer ]['status'] ); self::assertSame( 1, $request['records'][ $consumer ]['boot_succeeded'] ); - self::assertSame( $roots, $request['records'][ $consumer ]['resolved'] ); + self::assertSame( $skipped, $request['records'][ $consumer ]['skipped'] ); } } } diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php index c3c5c4d4..14f177a4 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php @@ -294,6 +294,14 @@ public function test_an_expired_notice_is_not_listed_and_does_not_show(): void { self::assertSame( array(), $notices->get_notices( NoticeScope::Request ) ); } + public function test_a_notice_that_has_not_expired_yet_is_listed_and_shows(): void { + $notices = $this->notices(); + $notices->add( $this->notice( 'new', NoticeScope::Site, persistent: true, expires_at: \time() + HOUR_IN_SECONDS ) ); + + self::assertSame( array( 'new' ), self::ids( $notices->get_notices( NoticeScope::Site ) ) ); + self::assertSame( array( $this->dom_id( 'site', 'new' ) ), \array_keys( self::notices_in( $this->render( $this->admin, $notices ) ) ) ); + } + public function test_the_visibility_filter_hides_a_notice_without_consuming_it_unless_it_returns_true(): void { $calls = array(); add_filter( diff --git a/tests/Integration/Core/Caching/CachesTest.php b/tests/Integration/Core/Caching/CachesTest.php index 77b2493f..1ea96f8c 100644 --- a/tests/Integration/Core/Caching/CachesTest.php +++ b/tests/Integration/Core/Caching/CachesTest.php @@ -211,6 +211,40 @@ public function test_a_value_stored_without_the_envelope_reads_as_a_miss( string self::assertSame( array( 'key' => 'Default.' ), $cache->getMultiple( array( 'key' ), 'Default.' ) ); } + public static function unloadable_classes(): array { + $object = \sprintf( 'O:%d:"%s":0:{}', \strlen( 'Acme\\Caching\\Missing' ), 'Acme\\Caching\\Missing' ); + $case = 'Acme\\Caching\\MissingSuit:Hearts'; + + return array( + 'top-level' => array( $object ), + 'nested-in-array' => array( "a:1:{s:4:\"item\";$object}" ), + 'inside-object' => array( "O:8:\"stdClass\":1:{s:5:\"inner\";$object}" ), + 'enum-case' => array( \sprintf( 'E:%d:"%s";', \strlen( $case ), $case ) ), + ); + } + + public static function kinds_and_unloadable_classes(): array { + return self::cross( self::unloadable_classes() ); + } + + #[DataProvider( 'kinds_and_unloadable_classes' )] + public function test_an_entry_naming_a_class_that_no_longer_loads_reads_as_a_miss( string $kind, string $stored ): void { + $cache = $this->cache( $kind ); + $cache->set( 'other', 'Value.' ); + $this->plant( + $kind, + 'key', + array( + 'value' => $stored, + 'generation' => $this->generation( $kind ), + ) + ); + + self::assertSame( 'Default.', $cache->get( 'key', 'Default.' ) ); + self::assertFalse( $cache->has( 'key' ) ); + self::assertSame( array( 'key' => 'Default.' ), $cache->getMultiple( array( 'key' ), 'Default.' ) ); + } + public static function expired_lifetimes(): array { $inverted = new \DateInterval( 'PT1M' ); $inverted->invert = 1; diff --git a/tests/Integration/Core/Logging/GuardedLoggerTest.php b/tests/Integration/Core/Logging/GuardedLoggerTest.php new file mode 100644 index 00000000..2aa66eaf --- /dev/null +++ b/tests/Integration/Core/Logging/GuardedLoggerTest.php @@ -0,0 +1,89 @@ +records[] = array( $level, $message, $context ); + } +} + +final class ThrowingLogger extends PsrAbstractLogger { + public function __construct( + protected \Throwable $failure + ) {} + + #[\Override] + public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { + throw $this->failure; + } +} + +#[CoversClass( GuardedLogger::class )] +final class GuardedLoggerTest extends TestCase { + protected string $log_file; + + protected string|false $previous_log_file; + + #[\Override] + protected function setUp(): void { + $this->log_file = (string) \tempnam( \sys_get_temp_dir(), 'acme-log-' ); + $this->previous_log_file = \ini_set( 'error_log', $this->log_file ); + } + + #[\Override] + protected function tearDown(): void { + \ini_set( 'error_log', (string) $this->previous_log_file ); + wp_delete_file( $this->log_file ); + } + + public function test_a_record_passes_to_the_wrapped_logger_unchanged_and_skips_the_error_log(): void { + $inner = new RecordingLogger(); + + ( new GuardedLogger( $inner, 'acme' ) )->log( PsrLogLevel::INFO, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); + + self::assertSame( array( array( PsrLogLevel::INFO, 'Cache {name} is cold.', array( 'name' => 'acme' ) ) ), $inner->records ); + self::assertSame( '', \file_get_contents( $this->log_file ) ); + } + + public function test_a_record_whose_logger_throws_reaches_the_error_log_raw_with_its_context_as_json_and_the_logging_failure(): void { + $exception = new \RuntimeException( 'Cache is cold.' ); + $failure = new \UnexpectedValueException( 'The log is not writable.' ); + + ( new GuardedLogger( new ThrowingLogger( $failure ), 'acme' ) )->error( + 'Warm-up of {name} failed.', + array( + 'name' => 'acme', + 'exception' => $exception, + ) + ); + + $log = (string) \file_get_contents( $this->log_file ); + self::assertStringContainsString( '[acme.error] Warm-up of {name} failed. {"name":"acme"}' . "\n" . 'RuntimeException: Cache is cold. in ' . __FILE__ . ':' . $exception->getLine(), $log ); + self::assertStringContainsString( 'The logger failed: UnexpectedValueException: The log is not writable. in ' . __FILE__ . ':' . $failure->getLine(), $log ); + } + + public function test_a_level_psr_3_does_not_define_throws_before_the_wrapped_logger_is_called(): void { + $inner = new RecordingLogger(); + + try { + ( new GuardedLogger( $inner, 'acme' ) )->log( 'verbose', 'Cache is cold.' ); + self::fail( 'The level was accepted.' ); + } catch ( PsrInvalidArgumentException $exception ) { + self::assertSame( "Log level 'verbose' is not a PSR-3 level.", $exception->getMessage() ); + } + + self::assertSame( array(), $inner->records ); + self::assertSame( '', \file_get_contents( $this->log_file ) ); + } +} diff --git a/tests/Integration/Core/Migration/MigrationRunnerTest.php b/tests/Integration/Core/Migration/MigrationRunnerTest.php index c978bbed..22de6b7a 100644 --- a/tests/Integration/Core/Migration/MigrationRunnerTest.php +++ b/tests/Integration/Core/Migration/MigrationRunnerTest.php @@ -984,54 +984,41 @@ public function test_a_batched_step_finishes_through_real_scheduler_dispatch( st $clock = new SettableClock( new \DateTimeImmutable( '@' . \time() ) ); $batches = new RecordedBatches( 5, static fn () => $clock->advance( 10 ) ); $scheduler = 'production' === $kind ? new Scheduler( 'acme' ) : $this->make_wp_cron_scheduler(); - $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, $scheduler ) ); - $dispatches = 0; - while ( 0 < $this->count_pending_runs() && $dispatches < 5 ) { + $requests = 0; + while ( 1 > MigrationRunner::get_revision( $this->option ) && $requests < 5 ) { + // Each request's wp_loaded pass enqueues the runner, then its queue run dispatches what is due. remove_all_actions( "{$this->option}/migration/run" ); - $this->make_runner( array( $batches ), $clock, null, $scheduler )->register_hooks(); - // Each dispatch is a later request's queue run, by which the previous continuation is due. - 'production' === $kind ? $this->run_due_actions( \time() + MINUTE_IN_SECONDS ) : $this->run_due_cron_events( \time() + MINUTE_IN_SECONDS ); - ++$dispatches; + $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, $scheduler ) ); + 'production' === $kind ? $this->run_due_actions() : $this->run_due_cron_events(); + ++$requests; } - self::assertSame( 3, $dispatches ); + self::assertSame( 3, $requests ); self::assertSame( 5, $batches->processed ); self::assertSame( 1, MigrationRunner::get_revision( $this->option ) ); self::assertSame( 0, $this->count_pending_runs() ); } - public function test_a_spent_budget_leaves_nothing_due_for_the_queue_run_in_progress(): void { + public function test_a_spent_budget_leaves_nothing_due_for_the_queue_run_in_progress_and_the_next_request_resumes(): void { $clock = new SettableClock( new \DateTimeImmutable( '@' . \time() ) ); $batches = new RecordedBatches( 5, static fn () => $clock->advance( 30 ) ); $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( 'acme' ) ) ); - $claimed_at = \time(); $this->run_due_actions(); - $this->run_due_actions( $claimed_at ); + // A queue run that pauses between actions claims again later, as wp action-scheduler run --pause does. + $this->run_due_actions( \time() + MINUTE_IN_SECONDS ); - $pending = as_get_scheduled_actions( - array( - 'hook' => "{$this->option}/migration/run", - 'status' => \ActionScheduler_Store::STATUS_PENDING, - 'per_page' => -1, - ), - 'objects' - ); - self::assertCount( 1, $pending ); - self::assertGreaterThan( $claimed_at, \current( $pending )->get_schedule()->get_date()?->getTimestamp() ); self::assertSame( 1, $batches->processed ); - self::assertCount( - 1, - as_get_scheduled_actions( - array( - 'hook' => "{$this->option}/migration/run", - 'status' => \ActionScheduler_Store::STATUS_COMPLETE, - 'per_page' => -1, - ), - 'ids' - ) - ); + self::assertSame( 0, $this->count_pending_runs() ); + + remove_all_actions( "{$this->option}/migration/run" ); + $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( 'acme' ) ) ); + $this->run_due_actions(); + + self::assertSame( 2, $batches->processed ); + self::assertSame( 0, MigrationRunner::get_revision( $this->option ) ); + self::assertSame( 0, $this->count_pending_runs() ); } public function test_a_request_outside_the_runner_hook_enqueues_a_pending_batched_step_without_the_lock(): void { @@ -1081,6 +1068,36 @@ public function test_a_pending_batched_step_shows_plugin_activators_a_run_now_li self::assertStringNotContainsString( self::PROGRESS_NOTICE, $this->render_admin_notices() ); } + public function test_a_failed_batch_shows_its_error_with_a_run_now_link_that_completes_the_repaired_step(): void { + $broken = true; + $batches = new RecordedBatches( + 2, + static function () use ( &$broken ): void { + if ( $broken ) { + throw new \RuntimeException( 'The batch failed.' ); + } + } + ); + $this->make_runner( array( $batches ) )->register_hooks(); + do_action( "{$this->option}/migration/run" ); + $this->age_lock(); + $broken = false; + + wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); + $notices = $this->render_admin_notices(); + self::assertStringContainsString( self::ERROR_NOTICE . 'The batch failed.', $notices ); + self::assertStringNotContainsString( self::PROGRESS_NOTICE, $notices ); + self::assertSame( 1, \preg_match( '/Run the update now<\/a>/', $notices, $link ) ); + \parse_str( (string) wp_parse_url( \html_entity_decode( $link[1] ), \PHP_URL_QUERY ), $query ); + $_REQUEST['_wpnonce'] = $query['_wpnonce']; + + $this->post_run_now(); + + self::assertSame( 2, $batches->processed ); + self::assertSame( 1, MigrationRunner::get_revision( $this->option ) ); + self::assertFalse( get_option( "{$this->option}_error" ) ); + } + public static function invalid_error_rows(): array { $valid = array( 'v' => 1, diff --git a/tests/Integration/Core/PluginBootTest.php b/tests/Integration/Core/PluginBootTest.php index 8ce46836..9b60b0bd 100644 --- a/tests/Integration/Core/PluginBootTest.php +++ b/tests/Integration/Core/PluginBootTest.php @@ -144,6 +144,7 @@ public function test_the_same_failure_is_logged_once_an_hour(): void { self::assertSame( 'error', $this->logger->records[0][0] ); self::assertSame( 'Boot failed; the plugin did not finish starting: {message}', $this->logger->records[0][1] ); self::assertSame( 'first failure', $this->logger->records[0][2]['message'] ); + self::assertEqualsWithDelta( \time() + HOUR_IN_SECONDS, (int) get_option( '_transient_timeout_acme_boot_failure_' . \md5( 'first failure' ) ), 5 ); } public function test_distinct_failures_are_each_logged_once(): void { diff --git a/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php b/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php index 5a28e1ad..4aa88d3e 100644 --- a/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php +++ b/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php @@ -115,6 +115,19 @@ public function test_the_site_that_loaded_the_plugin_schedules_after_a_switch_is self::assertSame( array(), $this->doing_it_wrong ); } + public function test_a_switch_to_the_site_that_loaded_the_plugin_still_schedules(): void { + $scheduler = $this->make_scheduler(); + $hook = new ScheduledHook( $this->hook ); + switch_to_blog( get_current_blog_id() ); + + $enqueued = $scheduler->enqueue( $hook ); + restore_current_blog(); + + self::assertTrue( $enqueued ); + self::assertSame( array( get_main_site_id() ), $this->writes ); + self::assertSame( array(), $this->doing_it_wrong ); + } + // The network carries no WooCommerce, so the default backends resolve to WP-Cron alone. protected function make_scheduler(): Scheduler { $scheduler = new Scheduler( 'acme' ); diff --git a/tests/Integration/Core/Scheduling/SchedulerTest.php b/tests/Integration/Core/Scheduling/SchedulerTest.php index 1f4a8169..73b3e1f7 100644 --- a/tests/Integration/Core/Scheduling/SchedulerTest.php +++ b/tests/Integration/Core/Scheduling/SchedulerTest.php @@ -77,6 +77,45 @@ protected function tearDown(): void { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE hook LIKE %s', $wpdb->actionscheduler_actions, $wpdb->esc_like( $this->hook ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. } + public static function valid_groups(): array { + return array( + 'one-letter' => array( 'a' ), + 'hook-prefix' => array( 'dws_tpl' ), + 'mixed-case-hyphen' => array( 'Acme-2_tpl' ), + '190-bytes' => array( \str_repeat( 'a', 190 ) ), + ); + } + + #[DataProvider( 'valid_groups' )] + public function test_a_valid_group_constructs_a_scheduler( string $group ): void { + self::assertInstanceOf( Scheduler::class, new Scheduler( $group ) ); + } + + public static function invalid_groups(): array { + return array( + 'empty' => array( '' ), + 'zero' => array( '0' ), + 'leading-digit' => array( '1acme' ), + 'space' => array( 'acme tpl' ), + 'dot' => array( 'acme.tpl' ), + 'trailing-break' => array( "acme\n" ), + '191-bytes' => array( \str_repeat( 'a', 191 ) ), + ); + } + + #[DataProvider( 'invalid_groups' )] + public function test_an_invalid_group_is_rejected( string $group ): void { + $this->expectException( InvalidArgumentException::class ); + + new Scheduler( $group ); + } + + public function test_unscheduling_a_hook_rejects_an_empty_hook_name(): void { + $this->expectException( InvalidArgumentException::class ); + + ( new Scheduler( 'acme' ) )->unschedule_hook( '' ); + } + public static function schedulers(): array { return array( 'action-scheduler' => array( 'production' ), @@ -249,7 +288,8 @@ public static function writes(): array { public function test_a_write_before_action_scheduler_initializes_is_an_error_that_writes_nothing( \Closure $write ): void { $scheduler = $this->make_scheduler( 'production' ); unset( $GLOBALS['wp_actions']['action_scheduler_init'] ); - $this->writes = array(); + $GLOBALS['wp_current_filter'][] = 'init'; + $this->writes = array(); $result = $write( $scheduler, new ScheduledHook( $this->hook ) ); @@ -574,6 +614,7 @@ public function test_is_scheduled_is_false_with_a_notice_before_action_scheduler $scheduler = $this->make_scheduler( 'production' ); wp_schedule_single_event( \time() + 3600, $this->hook ); unset( $GLOBALS['wp_actions']['action_scheduler_init'] ); + $GLOBALS['wp_current_filter'][] = 'init'; self::assertFalse( $scheduler->is_scheduled( new ScheduledHook( $this->hook ) ) ); self::assertCount( 1, $this->doing_it_wrong ); @@ -707,6 +748,17 @@ public function test_enqueueing_counts_a_running_invocation_on_action_scheduler_ self::assertSame( array(), $this->cron_events() ); } + public function test_an_action_scheduler_that_never_initialized_by_the_end_of_init_is_unsupported(): void { + $scheduler = $this->make_scheduler( 'production' ); + $this->report_action_scheduler_versions( array( '3.5.4' ) ); + unset( $GLOBALS['wp_actions']['action_scheduler_init'] ); + + self::assertTrue( $scheduler->enqueue( new ScheduledHook( $this->hook ) ) ); + + self::assertSame( array(), $this->pending_action_ids() ); + self::assertCount( 1, $this->cron_events() ); + } + public function test_action_scheduler_below_the_floor_is_still_read_and_cleared(): void { $scheduler = $this->make_scheduler( 'production' ); $hook = new ScheduledHook( $this->hook ); @@ -724,6 +776,7 @@ public function test_one_scheduler_follows_action_scheduler_readiness_from_call_ $hook = new ScheduledHook( $this->hook ); $ready = $GLOBALS['wp_actions']['action_scheduler_init']; unset( $GLOBALS['wp_actions']['action_scheduler_init'] ); + $GLOBALS['wp_current_filter'][] = 'init'; self::assert_wp_error( $scheduler->enqueue( $hook ), 'scheduler_not_ready' ); $GLOBALS['wp_actions']['action_scheduler_init'] = $ready; @@ -764,6 +817,17 @@ static function ( mixed ...$passed ) use ( &$received ): void { self::assertSame( array( $args ), $received ); } + public function test_ensuring_replaces_a_cron_expression_chain_with_one_interval_chain(): void { + as_schedule_cron_action( \time(), '0 * * * *', $this->hook, array(), self::GROUP ); + + self::assertTrue( $this->make_scheduler( 'production' )->ensure_recurring( new ScheduledHook( $this->hook ), Recurrence::from_interval( 3600 ) ) ); + + $pending = $this->pending_action_ids(); + self::assertCount( 1, $pending ); + self::assertInstanceOf( \ActionScheduler_IntervalSchedule::class, \ActionScheduler::store()->fetch_action( $pending[0] )->get_schedule() ); + self::assertSame( 3600, \ActionScheduler::store()->fetch_action( $pending[0] )->get_schedule()->get_recurrence() ); + } + protected static function assert_wp_error( mixed $actual, string $code ): void { self::assertInstanceOf( \WP_Error::class, $actual ); self::assertSame( $code, $actual->get_error_code() ); diff --git a/tests/Integration/Core/Storage/LockTest.php b/tests/Integration/Core/Storage/LockTest.php index c37d4821..94bdefbb 100644 --- a/tests/Integration/Core/Storage/LockTest.php +++ b/tests/Integration/Core/Storage/LockTest.php @@ -113,6 +113,16 @@ static function () use ( $lock, &$claims ): void { self::assertSame( self::hold( 'third', 1800000061 ), $this->stored() ); } + public function test_a_hold_released_between_the_failed_insert_and_the_read_is_busy(): void { + $lock = $this->make_lock(); + $lock->acquire( 'first' ); + $this->clock->advance( 10 ); + $this->before_query( 'SELECT option_value', static fn () => $lock->release( 'first' ) ); + + self::assertSame( LockClaim::Busy, $lock->acquire( 'second' ) ); + self::assertNull( $this->stored() ); + } + public function test_refreshing_extends_the_hold_to_a_full_ttl_from_now(): void { $lock = $this->make_lock(); $lock->acquire( 'first' ); diff --git a/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php b/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php index 3018f6c4..a15e3552 100644 --- a/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php +++ b/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php @@ -14,6 +14,8 @@ trait UninstallFixturesTrait { protected array $ids = array(); + protected array $id_reads = array(); + protected function footprint(): array { $p = $this->prefix; @@ -244,6 +246,25 @@ protected function network_option_exists( string $name ): bool { : $this->option_exists( $name ); } + // The engine reads row ids under the alias id, one page per query. + protected function record_id_reads(): void { + add_filter( + 'query', + function ( string $query ): string { + if ( \str_contains( $query, ' AS id, ' ) ) { + $this->id_reads[] = $query; + } + + return $query; + } + ); + } + + protected function assert_id_reads_paged(): void { + self::assertGreaterThan( 1, \count( $this->id_reads ) ); + self::assertSame( array(), \array_values( \array_filter( $this->id_reads, static fn ( string $query ): bool => ! \str_contains( $query, 'LIMIT 1000' ) ) ) ); + } + protected function user_meta_exists( string $key ): bool { global $wpdb; diff --git a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php index fe0261c0..cfadcbfa 100644 --- a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php @@ -65,6 +65,70 @@ public function test_a_subsite_only_plugin_deleted_from_the_network_admin_cleans self::assertSame( array( "cleanup:site:$this->other_site" ), $this->summarize_calls() ); } + public function test_a_subsite_pass_leaves_the_main_sites_prefixed_options_site_user_meta_meta_tables_and_tables_alone(): void { + $p = $this->prefix; + $sites = array( $this->main_site, $this->other_site ); + $state = fn ( int $site_id ): array => $this->on_site( + $site_id, + function () use ( $p ): array { + global $wpdb; + + return array( + $this->option_exists( "{$p}_cache_a" ), + $this->user_meta_exists( $wpdb->get_blog_prefix() . "{$p}_site_a" ), + '0' !== $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE meta_key = %s', "$wpdb->prefix{$p}_itemmeta", "{$p}_flag" ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads the test's own table. + $this->table_exists( "{$p}_log" ), + ); + } + ); + foreach ( $sites as $site_id ) { + $this->on_site( + $site_id, + function () use ( $p ): void { + global $wpdb; + + update_option( "{$p}_cache_a", 'x' ); + add_user_meta( $this->user, $wpdb->get_blog_prefix() . "{$p}_site_a", 'x' ); + $wpdb->query( $wpdb->prepare( 'CREATE TABLE %i ( id INT )', "$wpdb->prefix{$p}_log" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Creates the test's own table. + $wpdb->query( $wpdb->prepare( 'CREATE TABLE %i ( meta_id INT, meta_key VARCHAR(255) )', "$wpdb->prefix{$p}_itemmeta" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Creates the test's own table. + $wpdb->insert( "$wpdb->prefix{$p}_itemmeta", array( 'meta_key' => "{$p}_flag" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Seeds the test's own table. + } + ); + } + $this->on_site( $this->other_site, fn (): bool => update_option( "{$p}_remove_data", 'yes' ) ); + + try { + Uninstaller::run( + self::PLUGIN_FILE, + array( + 'option_prefixes' => array( "{$p}_cache_" ), + 'meta' => array( 'item' => array( "{$p}_flag" ) ), + 'user_meta_site_prefixes' => array( "{$p}_site_" ), + 'tables' => array( "{$p}_log" ), + ), + array( 'meta_types' => array( 'item' => array( 'meta_tables' => array( "{$p}_itemmeta" ) ) ) ) + $this->args() + ); + $main = $state( $this->main_site ); + $other = $state( $this->other_site ); + } finally { + foreach ( $sites as $site_id ) { + $this->on_site( + $site_id, + function () use ( $p ): void { + global $wpdb; + + foreach ( array( "{$p}_log", "{$p}_itemmeta" ) as $table ) { + $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . $table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Drops the test's own tables. + } + } + ); + } + } + + self::assertSame( array( true, true, true, true ), $main ); + self::assertSame( array( false, false, false, false ), $other ); + } + public static function constant_consents(): array { return array( 'with-the-constant' => array( true ), @@ -98,6 +162,18 @@ public function test_the_network_pass_runs_only_on_the_consent_constant( bool $c self::assertSame( ! $constant, $this->network_option_exists( "{$this->prefix}_network_settings" ) ); } + public function test_the_network_pass_reads_a_global_user_meta_key_in_pages_and_deletes_every_row(): void { + for ( $row = 0; $row <= 1000; ++$row ) { + add_user_meta( $this->user, "{$this->prefix}_global_state", (string) $row ); + } + $this->record_id_reads(); + + Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args( true ) ); + + self::assertFalse( $this->user_meta_exists( "{$this->prefix}_global_state" ) ); + $this->assert_id_reads_paged(); + } + public function test_the_network_pass_keeps_a_network_option_that_differs_only_in_letter_case(): void { global $wpdb; $twin = \strtoupper( "{$this->prefix}_network_settings" ); diff --git a/tests/Integration/Core/Uninstall/UninstallerTest.php b/tests/Integration/Core/Uninstall/UninstallerTest.php index 2cd3aadf..db14305f 100644 --- a/tests/Integration/Core/Uninstall/UninstallerTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerTest.php @@ -79,6 +79,79 @@ public function test_a_consenting_run_without_a_current_user_deletes_exactly_the self::assertFalse( $this->option_exists( "{$this->prefix}_remove_data" ) ); } + public function test_a_key_on_more_rows_than_one_read_takes_is_read_in_pages_and_deleted_in_full(): void { + $object_id = $this->make_post( 'post', 'publish' ); + for ( $row = 0; $row <= 1000; ++$row ) { + add_metadata( 'post', $object_id, "{$this->prefix}_flag", (string) $row ); + } + $this->record_id_reads(); + + Uninstaller::run( self::PLUGIN_FILE, array( 'meta' => array( 'post' => array( "{$this->prefix}_flag" ) ) ), self::stored_consent( 'yes' )( $this->prefix ) ); + + self::assertFalse( $this->meta_exists( 'post', "{$this->prefix}_flag" ) ); + $this->assert_id_reads_paged(); + } + + public function test_a_post_of_an_unregistered_type_is_deleted_with_its_term_relationships(): void { + global $wpdb; + + $term = wp_insert_term( "$this->prefix-tagged", 'category' ); + self::assertIsArray( $term ); + $this->ids['term'] = (int) $term['term_id']; + $post_id = $this->make_post( "{$this->prefix}_type", 'publish' ); + wp_set_object_terms( $post_id, array( $this->ids['term'] ), 'category' ); + + Uninstaller::run( self::PLUGIN_FILE, array( 'post_types' => array( "{$this->prefix}_type" ) ), self::stored_consent( 'yes' )( $this->prefix ) ); + + self::assertFalse( $this->post_exists( $post_id ) ); + self::assertSame( '0', $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE object_id = %d', $wpdb->term_relationships, $post_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads past the term cache. + } + + public function test_a_deleted_posts_id_keeps_its_relationships_in_a_taxonomy_of_another_object_type(): void { + global $wpdb; + + $taxonomy = "{$this->prefix}_team"; + register_taxonomy( $taxonomy, 'user' ); + $term = wp_insert_term( "$this->prefix-team", $taxonomy ); + try { + self::assertIsArray( $term ); + $post_id = $this->make_post( "{$this->prefix}_type", 'publish' ); + wp_set_object_terms( $post_id, array( (int) $term['term_id'] ), $taxonomy ); // Stands for the user that shares the post's id. + + Uninstaller::run( self::PLUGIN_FILE, array( 'post_types' => array( "{$this->prefix}_type" ) ), self::stored_consent( 'yes' )( $this->prefix ) ); + + self::assertFalse( $this->post_exists( $post_id ) ); + self::assertSame( '1', $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE object_id = %d', $wpdb->term_relationships, $post_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads past the term cache. + } finally { + if ( \is_array( $term ) ) { + wp_delete_term( (int) $term['term_id'], $taxonomy ); + } + unregister_taxonomy( $taxonomy ); + } + } + + public function test_a_post_whose_delete_wordpress_refuses_keeps_its_term_relationships(): void { + global $wpdb; + + $term = wp_insert_term( "$this->prefix-tagged", 'category' ); + self::assertIsArray( $term ); + $this->ids['term'] = (int) $term['term_id']; + $post_id = $this->make_post( "{$this->prefix}_type", 'publish' ); + wp_set_object_terms( $post_id, array( $this->ids['term'] ), 'category' ); + add_filter( 'pre_delete_post', '__return_false' ); + + try { + Uninstaller::run( self::PLUGIN_FILE, array( 'post_types' => array( "{$this->prefix}_type" ) ), self::stored_consent( 'yes' )( $this->prefix ) ); + self::fail( 'The refused delete was not reported.' ); + } catch ( RuntimeException ) { + self::assertTrue( $this->post_exists( $post_id ) ); + } finally { + remove_filter( 'pre_delete_post', '__return_false' ); // The fixtures' own deletes must not be refused. + } + + self::assertSame( '1', $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE object_id = %d', $wpdb->term_relationships, $post_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads past the term cache. + } + public function test_the_cleanup_runs_first_while_the_footprint_and_the_consent_row_are_stored(): void { $consent_option = "{$this->prefix}_cache_remove_data"; update_option( $consent_option, 'yes' ); @@ -253,20 +326,21 @@ public function test_an_action_scheduler_hook_or_group_named_0_cancels_no_other_ public static function failing_statements(): array { return array( - 'an-option-delete' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', 'option_exists', '_settings' ), - 'a-prefix-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => 'SELECT DISTINCT CAST( `option_name`', '_cache_', 'option_exists', '_cache_a' ), - 'a-post-type-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "AS value FROM `$wpdb->posts`", '_type', 'post_type_has_posts', '_type' ), - 'a-network-option' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_network_settings'", '_network_settings', 'network_option_exists', '_network_settings' ), + 'an-option-delete' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', 'option_exists', '_settings' ), + 'a-prefix-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => 'SELECT DISTINCT CAST( `option_name`', '_cache_', 'option_exists', '_cache_a' ), + 'a-post-type-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "AS value FROM `$wpdb->posts`", '_type', 'post_type_has_posts', '_type' ), + 'a-network-option' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_network_settings'", '_network_settings', 'network_option_exists', '_network_settings' ), + 'an-option-delete-with-prefix-consent' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', 'option_exists', '_settings', '_cache_remove_data' ), ); } #[DataProvider( 'failing_statements' )] - public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_the_rest_so_a_retry_completes( \Closure $needle, string $subject, string $kept, string $row ): void { + public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_the_rest_so_a_retry_completes( \Closure $needle, string $subject, string $kept, string $row, string $consent = '_remove_data' ): void { global $wpdb; $this->seed_footprint(); - update_option( "{$this->prefix}_remove_data", 'yes' ); - $args = array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) + $this->recording_cleanup(); + update_option( $this->prefix . $consent, 'yes' ); + $args = array( 'consent' => array( 'option' => $this->prefix . $consent ) ) + $this->recording_cleanup(); $this->fail_queries( $needle( $wpdb, $this->prefix ) ); $failure = null; @@ -281,14 +355,14 @@ public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_t self::assertTrue( $this->$kept( $this->prefix . $row ) ); self::assertFalse( $this->table_exists( "{$this->prefix}_log" ) ); self::assertSame( array( 'cleanup' ), \array_column( $this->calls, 0 ) ); - self::assertTrue( $this->option_exists( "{$this->prefix}_remove_data" ) ); + self::assertTrue( $this->option_exists( $this->prefix . $consent ) ); remove_all_filters( 'query' ); $this->restore_query_errors(); Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), $args ); self::assertFalse( $this->$kept( $this->prefix . $row ) ); - self::assertFalse( $this->option_exists( "{$this->prefix}_remove_data" ) ); + self::assertFalse( $this->option_exists( $this->prefix . $consent ) ); } public static function refused_deletes(): array { diff --git a/tests/Integration/Personas/CorePersonaTest.php b/tests/Integration/Personas/CorePersonaTest.php index fb4b9912..f9cb284c 100644 --- a/tests/Integration/Personas/CorePersonaTest.php +++ b/tests/Integration/Personas/CorePersonaTest.php @@ -21,7 +21,6 @@ public function test_the_plugin_boots_its_components_and_runs_its_migration_with self::assertNull( $request['died'] ); self::assertFalse( $request['records']['woocommerce_present'] ); self::assertSame( 'booted', $request['records']['persona_core']['status'] ); - self::assertSame( array( 'Acme\\PersonaCore\\Scoped\\DeepWebSolutions\\Framework\\Core\\AdminNotice\\AdminNotices', 'Acme\\PersonaCore\\Greeting' ), $request['records']['persona_core']['resolved'] ); self::assertSame( 1, $request['records']['persona_core']['boot_succeeded'] ); self::assertSame( 'Acme\\PersonaCore\\Scoped\\DeepWebSolutions\\Framework\\Core\\Migration\\MigrationRunner', $request['records']['persona_core']['migrations'] ); self::assertTrue( $request['records']['greeting_shortcode'] ); diff --git a/tests/Integration/Personas/SettingsPersonaTest.php b/tests/Integration/Personas/SettingsPersonaTest.php index f634f426..7ad0aa87 100644 --- a/tests/Integration/Personas/SettingsPersonaTest.php +++ b/tests/Integration/Personas/SettingsPersonaTest.php @@ -36,7 +36,7 @@ public function test_a_rejected_limit_keeps_the_stored_one_and_its_message_shows self::assertIsString( $saved['location'] ); $page = $this->consumer_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $saved['user'], $saved['spec'] ); - self::assertStringContainsString( 'Limit was not saved, so it keeps its previous value. Enter a whole number of at least 1.', $page['body'] ); + self::assertStringContainsString( 'Limit was not saved and keeps its previous value. The other changes were saved. Enter a whole number of at least 1.', $page['body'] ); } // Posts the settings tab as the browser submits it after the user typed the limit. diff --git a/tests/Integration/Settings/AdminPage/OptionsPageTest.php b/tests/Integration/Settings/AdminPage/OptionsPageTest.php index 9b5eb172..60fcd2a9 100644 --- a/tests/Integration/Settings/AdminPage/OptionsPageTest.php +++ b/tests/Integration/Settings/AdminPage/OptionsPageTest.php @@ -146,7 +146,7 @@ public function test_a_rejected_field_keeps_its_value_and_its_message_shows_once $saved = $this->save( $spec, $this->admin, $data ); self::assertSame( array( 'name' => 'old' ), get_option( $this->option ) ); - self::assertSame( array( 'Name was not saved, so it keeps its previous value. Use at most four characters.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); + self::assertSame( array( 'Name was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } #[DataProvider( 'parents' )] @@ -168,7 +168,7 @@ public function test_a_rejection_without_a_message_says_that_the_value_is_not_va $saved = $this->save( $spec, $this->admin, $data ); self::assertSame( array( 'name' => 'old' ), get_option( $this->option ) ); - self::assertSame( array( 'Name was not saved because its value is not valid, so it keeps its previous value.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); + self::assertSame( array( 'Name was not saved because its value is not valid, and keeps its previous value. The other changes were saved.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } public function test_a_built_in_rejection_carries_what_to_enter(): void { @@ -179,7 +179,7 @@ public function test_a_built_in_rejection_carries_what_to_enter(): void { $saved = $this->save( $spec, $this->admin, $data ); self::assertSame( array( 'email' => 'ops@example.com' ), get_option( $this->option ) ); - self::assertSame( array( 'Email was not saved, so it keeps its previous value. Enter a valid email address, such as name@example.com.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); + self::assertSame( array( 'Email was not saved and keeps its previous value. The other changes were saved. Enter a valid email address, such as name@example.com.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } public function test_titles_and_messages_print_as_text_and_an_untitled_field_is_labelled_and_named_by_its_id(): void { @@ -208,8 +208,8 @@ public function test_titles_and_messages_print_as_text_and_an_untitled_field_is_ self::assertNull( self::document( $form )->querySelector( 'form em' ) ); self::assertSame( array( - 'Email was not saved, so it keeps its previous value. Enter a valid email address, such as name@example.com.', - 'site was not saved, so it keeps its previous value. Enter a valid URL, such as https://example.com.', + 'Email was not saved and keeps its previous value. The other changes were saved. Enter a valid email address, such as name@example.com.', + 'site was not saved and keeps its previous value. The other changes were saved. Enter a valid URL, such as https://example.com.', ), self::notices( $page ) ); @@ -586,6 +586,29 @@ public function test_each_tab_shows_its_own_form_and_an_unknown_tab_shows_the_fi self::assertNull( $single->querySelector( '.nav-tab' ) ); } + public function test_saving_the_second_of_two_tabs_on_one_option_keeps_the_first_tabs_field(): void { + $spec = $this->spec( + array( self::field( 'text', 'name' ) ), + array( + 'tabs' => array( + self::tab( 'general', $this->option, array( self::field( 'text', 'name' ) ) ), + self::tab( 'second', $this->option, array( self::field( 'text', 'other' ) ) ), + ), + ) + ); + $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'first' ) ) ); + + $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin, 'second' )['body'], array( "{$this->option}[other]" => 'second' ) ) ); + + self::assertSame( + array( + 'name' => 'first', + 'other' => 'second', + ), + get_option( $this->option ) + ); + } + protected function spec( array $fields, array $page = array(), array $extra = array() ): array { return array( 'id' => $this->id, diff --git a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php index c7eb084f..535a3cc6 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php @@ -64,8 +64,8 @@ public static function group_fields(): array { public static function silent_and_worded_rejections(): array { return array( - 'worded' => array( 'acme:reject', "Customer's note \\o/ was not saved, so it keeps its previous value. Use at most four characters." ), - 'silent' => array( 'acme:reject-silently', "Customer's note \\o/ was not saved because its value is not valid, so it keeps its previous value." ), + 'worded' => array( 'acme:reject', "Customer's note \\o/ was not saved and keeps its previous value. The other changes were saved. Use at most four characters." ), + 'silent' => array( 'acme:reject-silently', "Customer's note \\o/ was not saved because its value is not valid, and keeps its previous value. The other changes were saved." ), ); } @@ -426,7 +426,7 @@ public function test_a_membership_rejection_names_the_field_and_what_to_enter(): $saved = $this->save( $spec, $post_id, array(), array( $this->id => array( "_{$this->prefix}_color" => 'black' ) ) ); self::assertSame( array( 'red' ), $this->meta( $post_id, 'color' ) ); - self::assertSame( array( 'Color was not saved, so it keeps its previous value. Choose one of the available options.' ), self::rejections( $this->follow( $spec, $saved )['body'] ) ); + self::assertSame( array( 'Color was not saved and keeps its previous value. The other changes were saved. Choose one of the available options.' ), self::rejections( $this->follow( $spec, $saved )['body'] ) ); } public function test_a_new_post_renders_defaults_and_saves_them_on_publish(): void { @@ -682,7 +682,7 @@ public function test_a_rejection_in_the_block_editor_reaches_the_editor_after_th self::assertSame( array( 'kept' ), $this->meta( $post_id, 'note' ) ); self::assertNotNull( self::document( $redirected['body'] )->querySelector( 'form.metabox-base-form' ) ); - self::assertSame( array( 'Note was not saved, so it keeps its previous value. Use at most four characters.' ), \array_column( $delivered, 'content' ) ); + self::assertSame( array( 'Note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), \array_column( $delivered, 'content' ) ); self::assertSame( array(), self::rejections( $list['body'] ) ); } diff --git a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php index 207f3728..9322e367 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php @@ -114,7 +114,7 @@ public function test_update_saves_the_accepted_fields_and_shows_a_rejection_afte self::assertSame( array( 'O\'Brien C:\\dir' ), $this->meta( $term_id, 'name' ) ); self::assertSame( array( 'kept' ), $this->meta( $term_id, 'note' ) ); - self::assertSame( array( 'Note was not saved, so it keeps its previous value. Use at most four characters.' ), self::rejections( $next['body'] ) ); + self::assertSame( array( 'Note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::rejections( $next['body'] ) ); self::assertSame( array(), self::rejections( $after['body'] ) ); } diff --git a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php index 7322f7a8..942b43ee 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php @@ -131,7 +131,7 @@ public function test_both_screens_save_the_accepted_fields_and_show_a_rejection_ self::assertSame( array( 'O\'Brien C:\\dir' ), $this->meta( $user_id, 'name' ) ); self::assertSame( array( array( 'red', 'teal' ) ), $this->meta( $user_id, 'color' ) ); self::assertSame( array( 'kept' ), $this->meta( $user_id, 'note' ) ); - self::assertSame( array( 'Note was not saved, so it keeps its previous value. Use at most four characters.' ), self::rejections( $next['body'] ) ); + self::assertSame( array( 'Note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::rejections( $next['body'] ) ); self::assertSame( array(), self::rejections( $after['body'] ) ); } @@ -150,7 +150,7 @@ public function test_a_rejection_on_a_multisite_profile_shows_after_the_redirect } self::assertStringStartsWith( \explode( '/', $path )[0] . '/profile.php', \explode( '/wp-admin/', (string) $saved['location'], 2 )[1] ); - self::assertSame( array( 'Note was not saved, so it keeps its previous value. Use at most four characters.' ), self::rejections( $next['body'] ) ); + self::assertSame( array( 'Note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::rejections( $next['body'] ) ); } public function test_a_core_profile_error_shows_the_forms_rejection_in_the_same_response(): void { @@ -168,7 +168,7 @@ public function test_a_core_profile_error_shows_the_forms_rejection_in_the_same_ self::assertNull( $saved['location'] ); self::assertNotNull( self::document( $saved['body'] )->querySelector( 'div.error' ) ); - self::assertSame( array( 'Note was not saved, so it keeps its previous value. Use at most four characters.' ), self::rejections( $saved['body'] ) ); + self::assertSame( array( 'Note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::rejections( $saved['body'] ) ); self::assertSame( array( 'saved anyway' ), $this->meta( $this->subscriber, 'name' ) ); } diff --git a/tests/Integration/Settings/EditScreen/ObjectFormTest.php b/tests/Integration/Settings/EditScreen/ObjectFormTest.php index f8355c1c..f9c914fe 100644 --- a/tests/Integration/Settings/EditScreen/ObjectFormTest.php +++ b/tests/Integration/Settings/EditScreen/ObjectFormTest.php @@ -164,7 +164,7 @@ public function test_each_rejection_becomes_a_one_shot_error_notice_for_the_user $stored = $notices->get_notices( NoticeScope::User, $this->author_id ); self::assertCount( 1, $stored ); - self::assertSame( 'Note & more was not saved, so it keeps its previous value. Use at most four characters.', $stored[0]->message ); + self::assertSame( 'Note & more was not saved and keeps its previous value. The other changes were saved. Use at most four characters.', $stored[0]->message ); self::assertSame( NoticeType::Error, $stored[0]->type ); self::assertFalse( $stored[0]->persistent ); self::assertSame( 'Saved.', get_post_meta( $this->post_id, "_{$this->prefix}_name", true ) ); @@ -182,7 +182,7 @@ public function test_a_second_rejection_of_a_field_replaces_the_first(): void { $stored = $notices->get_notices( NoticeScope::User, $this->author_id ); self::assertCount( 1, $stored ); self::assertSame( 'rejection-' . \md5( "{$this->prefix}_details|_{$this->prefix}_note" ), $stored[0]->id ); - self::assertSame( 'Renamed note was not saved, so it keeps its previous value. Use at most four characters.', $stored[0]->message ); + self::assertSame( 'Renamed note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.', $stored[0]->message ); } public function test_a_rejection_whose_notice_cannot_be_stored_is_logged_and_keeps_the_accepted_values(): void { diff --git a/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php b/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php index 2b6d7e21..95492de0 100644 --- a/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php +++ b/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Settings\EditScreen\Stores; use DeepWebSolutions\Framework\Settings\EditScreen\Stores\MetadataStore; +use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use PHPUnit\Framework\Attributes\After; @@ -77,6 +78,34 @@ public function test_values_are_stored_as_given_backslashes_included( string $me self::assertSame( array( array( 'C:\\dir', 'red' ) ), get_metadata( $meta_type, $object_id, "_{$this->prefix}_list", false ) ); } + public static function unstorable_values(): array { + return array( + 'object' => array( (object) array( 'path' => 'C:\\reports' ) ), + 'nested-object' => array( array( 'red', array( (object) array( 'path' => 'C:\\reports' ) ) ) ), + ); + } + + #[DataProvider( 'unstorable_values' )] + public function test_a_value_holding_anything_but_scalars_null_and_arrays_throws_before_any_key_is_written( mixed $value ): void { + $object_id = $this->make_object( 'post' ); + + try { + ( new MetadataStore( 'post' ) )->update_values( + $object_id, + array( + "_{$this->prefix}_first" => 'Written first.', + "_{$this->prefix}_value" => $value, + ) + ); + self::fail( 'The store wrote a value it cannot keep.' ); + } catch ( InvalidArgumentException $exception ) { + self::assertStringContainsString( "_{$this->prefix}_value", $exception->getMessage() ); + } + + self::assertSame( array(), get_metadata( 'post', $object_id, "_{$this->prefix}_first", false ) ); + self::assertSame( array(), get_metadata( 'post', $object_id, "_{$this->prefix}_value", false ) ); + } + #[DataProvider( 'meta_types' )] public function test_a_stored_empty_value_survives_a_round_trip( string $meta_type ): void { $object_id = $this->make_object( $meta_type ); diff --git a/tests/Integration/Settings/FormsGetRejectionMessageTest.php b/tests/Integration/Settings/FormsGetRejectionMessageTest.php index 63ac4e4d..bc640bd2 100644 --- a/tests/Integration/Settings/FormsGetRejectionMessageTest.php +++ b/tests/Integration/Settings/FormsGetRejectionMessageTest.php @@ -9,10 +9,10 @@ #[CoversClass( Forms::class )] final class FormsGetRejectionMessageTest extends TestCase { public function test_a_worded_rejection_names_the_field_and_what_to_enter_as_plain_text(): void { - self::assertSame( "Customer's note was not saved, so it keeps its previous value. Use at most four characters.", Forms::get_rejection_message( "Customer's note", new \WP_Error( 'acme_rejected', 'Use at most four characters.' ) ) ); + self::assertSame( "Customer's note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.", Forms::get_rejection_message( "Customer's note", new \WP_Error( 'acme_rejected', 'Use at most four characters.' ) ) ); } public function test_a_silent_rejection_names_the_field_alone(): void { - self::assertSame( 'Note was not saved because its value is not valid, so it keeps its previous value.', Forms::get_rejection_message( 'Note', new \WP_Error( 'acme_rejected' ) ) ); + self::assertSame( 'Note was not saved because its value is not valid, and keeps its previous value. The other changes were saved.', Forms::get_rejection_message( 'Note', new \WP_Error( 'acme_rejected' ) ) ); } } diff --git a/tests/Integration/Settings/FormsRenderControlTest.php b/tests/Integration/Settings/FormsRenderControlTest.php index 6d03a7df..ff335540 100644 --- a/tests/Integration/Settings/FormsRenderControlTest.php +++ b/tests/Integration/Settings/FormsRenderControlTest.php @@ -90,6 +90,23 @@ public function test_help_text_is_linked_from_the_control_by_aria_describedby(): self::assertSame( 'Shown below.', self::text( $document->getElementById( 'acme.key.description' ) ) ); } + public static function non_input_controls(): array { + return array( + 'textarea' => array( 'textarea', array(), 'textarea' ), + 'select' => array( 'select', array( 'options' => array( 'a' => 'A' ) ), 'select:not([multiple])' ), + 'multiselect' => array( 'multiselect', array( 'options' => array( 'a' => 'A' ) ), 'select[multiple]' ), + ); + } + + #[DataProvider( 'non_input_controls' )] + public function test_help_text_is_linked_from_a_non_input_control_by_aria_describedby( string $type, array $keys, string $selector ): void { + $document = self::render( $type, array( 'desc' => 'Shown below.' ) + $keys ); + $control = $document->getElementById( 'acme.key' ); + + self::assertTrue( $control->matches( $selector ) ); + self::assertSame( array( 'Shown below.' ), self::descriptions( $document, $control ) ); + } + public function test_a_desc_and_a_string_desc_tip_each_get_their_own_description(): void { $document = self::render( 'text', @@ -127,6 +144,18 @@ public function test_a_checkbox_posts_a_hidden_no_before_its_yes(): void { self::assertFalse( self::render( 'checkbox', array(), 'no' )->getElementById( 'acme.key' )->hasAttribute( 'checked' ) ); } + public static function checked_values(): array { + return array( + 'true' => array( true ), + 'one' => array( '1' ), + ); + } + + #[DataProvider( 'checked_values' )] + public function test_a_checkbox_renders_checked_for_every_value_its_sanitizer_reads_as_yes( mixed $value ): void { + self::assertTrue( self::render( 'checkbox', array(), $value )->getElementById( 'acme.key' )->hasAttribute( 'checked' ) ); + } + public function test_a_checkbox_with_a_desc_is_named_by_its_title_and_labeled_by_its_desc(): void { $document = self::render( 'checkbox', diff --git a/tests/Integration/Settings/FormsSanitizeFieldsTest.php b/tests/Integration/Settings/FormsSanitizeFieldsTest.php index a0a4af36..d9e58b68 100644 --- a/tests/Integration/Settings/FormsSanitizeFieldsTest.php +++ b/tests/Integration/Settings/FormsSanitizeFieldsTest.php @@ -4,10 +4,187 @@ use DeepWebSolutions\Framework\Settings\Forms; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversClass( Forms::class )] final class FormsSanitizeFieldsTest extends TestCase { + protected const array OPTIONS = array( + 1 => 'One', + 2 => 'Two', + ); + + protected const array MESSAGES = array( + 'invalid_value' => 'Enter a valid value.', + 'invalid_choice' => 'Choose one of the available options.', + 'invalid_email' => 'Enter a valid email address, such as name@example.com.', + 'invalid_url' => 'Enter a valid URL, such as https://example.com.', + 'invalid_number' => 'Enter a number, such as 12 or 3.5.', + 'unknown_type' => 'This field cannot be saved because its type is not supported.', + 'missing_validate' => 'This field cannot be saved because nothing validates its value.', + ); + + public static function accepted(): array { + return array( + 'text-strips-tags-and-trims' => array( 'text', ' Hi there ', 'Hi there' ), + 'text-stringifies-an-int' => array( 'text', 5, '5' ), + 'text-keeps-a-lone-less-than' => array( 'text', 'a < b', 'a < b' ), + 'text-reads-a-typed-entity-as-text' => array( 'text', 'a < b', 'a < b' ), + 'textarea-keeps-newlines' => array( 'textarea', "One\ntwo", "One\ntwo" ), + 'textarea-keeps-a-lone-less-than' => array( 'textarea', "1 < 2\nOne", "1 < 2\nOne" ), + 'email-is-trimmed' => array( 'email', ' ops@example.com ', 'ops@example.com' ), + 'email-cleared' => array( 'email', '', '' ), + 'url-is-kept' => array( 'url', 'https://example.com/a?b=c', 'https://example.com/a?b=c' ), + 'url-gains-a-scheme' => array( 'url', 'example.com', 'http://example.com' ), + 'url-cleared' => array( 'url', ' ', '' ), + 'number-integer' => array( 'number', '12', '12' ), + 'number-is-trimmed' => array( 'number', ' 4 ', '4' ), + 'number-negative-decimal' => array( 'number', '-1.5', '-1.5' ), + 'number-leading-point' => array( 'number', '.5', '.5' ), + 'number-exponent' => array( 'number', '1e3', '1e3' ), + 'number-cleared' => array( 'number', '', '' ), + 'number-from-an-int' => array( 'number', 5, '5' ), + 'number-from-a-float' => array( 'number', 1.5, '1.5' ), + 'checkbox-yes' => array( 'checkbox', 'yes', 'yes' ), + 'checkbox-one' => array( 'checkbox', '1', 'yes' ), + 'checkbox-true' => array( 'checkbox', true, 'yes' ), + 'checkbox-no' => array( 'checkbox', 'no', 'no' ), + 'checkbox-empty' => array( 'checkbox', '', 'no' ), + 'checkbox-false' => array( 'checkbox', false, 'no' ), + 'select-integer-key-as-post-string' => array( 'select', '2', '2' ), + 'select-integer-key-as-int' => array( 'select', 2, '2' ), + 'radio-integer-key-as-post-string' => array( 'radio', '1', '1' ), + 'multiselect-post-strings' => array( 'multiselect', array( '1', '2' ), array( '1', '2' ) ), + 'multiselect-ints-as-strings' => array( 'multiselect', array( 2, 1 ), array( '2', '1' ) ), + 'multiselect-deduplicated' => array( 'multiselect', array( '2', '1', '2' ), array( '2', '1' ) ), + 'multiselect-reindexed' => array( 'multiselect', array( 3 => '2' ), array( '2' ) ), + 'multiselect-cleared' => array( 'multiselect', '', array() ), + 'checkboxes-post-strings' => array( 'checkboxes', array( '1', '2' ), array( '1', '2' ) ), + 'checkboxes-cleared' => array( 'checkboxes', '', array() ), + 'checkboxes-empty-array' => array( 'checkboxes', array(), array() ), + ); + } + + #[DataProvider( 'accepted' )] + public function test_a_value_without_validate_is_sanitized_by_its_type( string $type, mixed $raw, mixed $expected ): void { + self::assertSame( $expected, self::sanitize( self::field( $type ), $raw ) ); + } + + public static function rejected(): array { + return array( + 'text-array' => array( 'text', array( 'a' ), 'invalid_value' ), + 'text-object' => array( 'text', new \stdClass(), 'invalid_value' ), + 'text-bool' => array( 'text', true, 'invalid_value' ), + 'text-null' => array( 'text', null, 'invalid_value' ), + 'textarea-array' => array( 'textarea', array( 'a' ), 'invalid_value' ), + 'email-without-a-dotted-domain' => array( 'email', 'ops@localhost', 'invalid_email' ), + 'email-the-sanitizer-rewrites' => array( 'email', 'jöhn@example.com', 'invalid_email' ), + 'email-not-an-address' => array( 'email', 'not an email', 'invalid_email' ), + 'email-array' => array( 'email', array( 'ops@example.com' ), 'invalid_value' ), + 'url-script' => array( 'url', 'javascript:alert(1)', 'invalid_url' ), + 'url-mistyped-scheme' => array( 'url', 'htps://example.com', 'invalid_url' ), + 'url-array' => array( 'url', array( 'https://example.com' ), 'invalid_value' ), + 'number-leading-plus' => array( 'number', '+1', 'invalid_number' ), + 'number-trailing-point' => array( 'number', '1.', 'invalid_number' ), + 'number-trailing-letters' => array( 'number', '12abc', 'invalid_number' ), + 'number-decimal-comma' => array( 'number', '1,5', 'invalid_number' ), + 'number-overflowing' => array( 'number', '1e999', 'invalid_number' ), + 'number-infinite-float' => array( 'number', \INF, 'invalid_number' ), + 'number-nan' => array( 'number', \NAN, 'invalid_number' ), + 'number-array' => array( 'number', array( '1' ), 'invalid_value' ), + 'checkbox-other-string' => array( 'checkbox', 'maybe', 'invalid_choice' ), + 'checkbox-array' => array( 'checkbox', array( 'yes' ), 'invalid_choice' ), + 'select-unknown-choice' => array( 'select', '3', 'invalid_choice' ), + 'select-empty-choice' => array( 'select', '', 'invalid_choice' ), + 'select-array' => array( 'select', array( '1' ), 'invalid_value' ), + 'radio-unknown-choice' => array( 'radio', '3', 'invalid_choice' ), + 'multiselect-unknown-choice' => array( 'multiselect', array( '1', '3' ), 'invalid_choice' ), + 'multiselect-string' => array( 'multiselect', '1', 'invalid_value' ), + 'multiselect-nested-array' => array( 'multiselect', array( array( '1' ) ), 'invalid_value' ), + 'checkboxes-unknown-choice' => array( 'checkboxes', array( '3' ), 'invalid_choice' ), + 'unknown-type' => array( 'acme_color', '#fff', 'unknown_type' ), + ); + } + + #[DataProvider( 'rejected' )] + public function test_a_value_without_validate_is_rejected_with_the_message_of_its_code( string $type, mixed $raw, string $code ): void { + $result = self::sanitize( self::field( $type ), $raw ); + + self::assertInstanceOf( \WP_Error::class, $result ); + self::assertSame( $code, $result->get_error_code() ); + self::assertSame( self::MESSAGES[ $code ], $result->get_error_message() ); + } + + public function test_a_radio_rejects_a_disabled_choice_and_accepts_an_enabled_one(): void { + $field = self::field( 'radio' ) + array( 'disabled' => array( 2 ) ); + + self::assertSame( 'invalid_choice', self::sanitize( $field, '2' )->get_error_code() ); + self::assertSame( '1', self::sanitize( $field, '1' ) ); + } + + public function test_a_custom_type_without_validate_is_rejected(): void { + $field = array( + 'type' => 'text', + 'render' => static fn (): string => '', + ); + + $result = self::sanitize( $field, 'x' ); + + self::assertSame( 'missing_validate', $result->get_error_code() ); + self::assertSame( self::MESSAGES['missing_validate'], $result->get_error_message() ); + } + + public function test_validate_receives_the_raw_submission_alone_and_its_return_is_stored_as_is(): void { + $calls = array(); + $field = array( + 'type' => 'select', + 'options' => array( 'a' => 'A' ), + 'validate' => static function ( mixed ...$arguments ) use ( &$calls ): array { + $calls[] = $arguments; + return array( 'not a choice' ); + }, + ); + + self::assertSame( array( 'not a choice' ), self::sanitize( $field, ' z ' ) ); + self::assertSame( array( array( ' z ' ) ), $calls ); + } + + public function test_validate_receives_a_cleared_multi_value_field_as_an_empty_array(): void { + $received = null; + $field = array( + 'type' => 'checkboxes', + 'options' => array( 'a' => 'A' ), + 'validate' => static function ( mixed $raw ) use ( &$received ): mixed { + $received = $raw; + return $raw; + }, + ); + + self::assertSame( array(), self::sanitize( $field, '' ) ); + self::assertSame( array(), $received ); + } + + public function test_a_string_callable_validates_with_one_argument(): void { + $field = array( + 'type' => 'text', + 'validate' => 'wp_kses_post', + ); + + $raw = 'Docs'; + + self::assertSame( wp_kses_post( $raw ), self::sanitize( $field, $raw ) ); + } + + public function test_a_validate_rejection_passes_through(): void { + $error = new \WP_Error( 'acme_too_long', 'Use at most ten characters.' ); + $field = array( + 'type' => 'text', + 'validate' => static fn ( mixed $raw ): \WP_Error => $error, + ); + + self::assertSame( $error, self::sanitize( $field, 'x' ) ); + } + public function test_only_defined_present_and_enabled_fields_get_an_entry(): void { $result = Forms::sanitize_fields( array( @@ -120,4 +297,17 @@ public function test_merging_the_values_onto_the_stored_row_keeps_every_untouche \array_replace( $stored, $result['values'] ) ); } + + protected static function field( string $type ): array { + return array( + 'type' => $type, + 'options' => self::OPTIONS, + ); + } + + protected static function sanitize( array $field, mixed $raw ): mixed { + $result = Forms::sanitize_fields( array( array( 'id' => 'key' ) + $field ), array( 'key' => $raw ) ); + + return $result['errors']['key'] ?? $result['values']['key']; + } } diff --git a/tests/Integration/Settings/FormsSanitizeValueTest.php b/tests/Integration/Settings/FormsSanitizeValueTest.php deleted file mode 100644 index 5c055fcf..00000000 --- a/tests/Integration/Settings/FormsSanitizeValueTest.php +++ /dev/null @@ -1,191 +0,0 @@ - 'One', - 2 => 'Two', - ); - - protected const array MESSAGES = array( - 'invalid_value' => 'Enter a valid value.', - 'invalid_choice' => 'Choose one of the available options.', - 'invalid_email' => 'Enter a valid email address, such as name@example.com.', - 'invalid_url' => 'Enter a valid URL, such as https://example.com.', - 'invalid_number' => 'Enter a number, such as 12 or 3.5.', - 'unknown_type' => 'This field cannot be saved because its type is not supported.', - 'missing_validate' => 'This field cannot be saved because nothing validates its value.', - ); - - public static function accepted(): array { - return array( - 'text-strips-tags-and-trims' => array( 'text', ' Hi there ', 'Hi there' ), - 'text-stringifies-an-int' => array( 'text', 5, '5' ), - 'textarea-keeps-newlines' => array( 'textarea', "One\ntwo", "One\ntwo" ), - 'email-is-trimmed' => array( 'email', ' ops@example.com ', 'ops@example.com' ), - 'email-cleared' => array( 'email', '', '' ), - 'url-is-kept' => array( 'url', 'https://example.com/a?b=c', 'https://example.com/a?b=c' ), - 'url-gains-a-scheme' => array( 'url', 'example.com', 'http://example.com' ), - 'url-cleared' => array( 'url', ' ', '' ), - 'number-integer' => array( 'number', '12', '12' ), - 'number-is-trimmed' => array( 'number', ' 4 ', '4' ), - 'number-negative-decimal' => array( 'number', '-1.5', '-1.5' ), - 'number-leading-point' => array( 'number', '.5', '.5' ), - 'number-exponent' => array( 'number', '1e3', '1e3' ), - 'number-cleared' => array( 'number', '', '' ), - 'number-from-an-int' => array( 'number', 5, '5' ), - 'number-from-a-float' => array( 'number', 1.5, '1.5' ), - 'checkbox-yes' => array( 'checkbox', 'yes', 'yes' ), - 'checkbox-one' => array( 'checkbox', '1', 'yes' ), - 'checkbox-true' => array( 'checkbox', true, 'yes' ), - 'checkbox-no' => array( 'checkbox', 'no', 'no' ), - 'checkbox-empty' => array( 'checkbox', '', 'no' ), - 'checkbox-false' => array( 'checkbox', false, 'no' ), - 'select-integer-key-as-post-string' => array( 'select', '2', '2' ), - 'select-integer-key-as-int' => array( 'select', 2, '2' ), - 'radio-integer-key-as-post-string' => array( 'radio', '1', '1' ), - 'multiselect-post-strings' => array( 'multiselect', array( '1', '2' ), array( '1', '2' ) ), - 'multiselect-ints-as-strings' => array( 'multiselect', array( 2, 1 ), array( '2', '1' ) ), - 'multiselect-deduplicated' => array( 'multiselect', array( '2', '1', '2' ), array( '2', '1' ) ), - 'multiselect-reindexed' => array( 'multiselect', array( 3 => '2' ), array( '2' ) ), - 'multiselect-cleared' => array( 'multiselect', '', array() ), - 'checkboxes-post-strings' => array( 'checkboxes', array( '1', '2' ), array( '1', '2' ) ), - 'checkboxes-cleared' => array( 'checkboxes', '', array() ), - 'checkboxes-empty-array' => array( 'checkboxes', array(), array() ), - ); - } - - #[DataProvider( 'accepted' )] - public function test_a_value_without_validate_is_sanitized_by_its_type( string $type, mixed $raw, mixed $expected ): void { - self::assertSame( $expected, Forms::sanitize_value( self::field( $type ), $raw ) ); - } - - public static function rejected(): array { - return array( - 'text-array' => array( 'text', array( 'a' ), 'invalid_value' ), - 'text-object' => array( 'text', new \stdClass(), 'invalid_value' ), - 'text-bool' => array( 'text', true, 'invalid_value' ), - 'text-null' => array( 'text', null, 'invalid_value' ), - 'textarea-array' => array( 'textarea', array( 'a' ), 'invalid_value' ), - 'email-without-a-dotted-domain' => array( 'email', 'ops@localhost', 'invalid_email' ), - 'email-the-sanitizer-rewrites' => array( 'email', 'jöhn@example.com', 'invalid_email' ), - 'email-not-an-address' => array( 'email', 'not an email', 'invalid_email' ), - 'email-array' => array( 'email', array( 'ops@example.com' ), 'invalid_value' ), - 'url-script' => array( 'url', 'javascript:alert(1)', 'invalid_url' ), - 'url-mistyped-scheme' => array( 'url', 'htps://example.com', 'invalid_url' ), - 'url-array' => array( 'url', array( 'https://example.com' ), 'invalid_value' ), - 'number-leading-plus' => array( 'number', '+1', 'invalid_number' ), - 'number-trailing-point' => array( 'number', '1.', 'invalid_number' ), - 'number-trailing-letters' => array( 'number', '12abc', 'invalid_number' ), - 'number-decimal-comma' => array( 'number', '1,5', 'invalid_number' ), - 'number-overflowing' => array( 'number', '1e999', 'invalid_number' ), - 'number-infinite-float' => array( 'number', \INF, 'invalid_number' ), - 'number-nan' => array( 'number', \NAN, 'invalid_number' ), - 'number-array' => array( 'number', array( '1' ), 'invalid_value' ), - 'checkbox-other-string' => array( 'checkbox', 'maybe', 'invalid_choice' ), - 'checkbox-array' => array( 'checkbox', array( 'yes' ), 'invalid_choice' ), - 'select-unknown-choice' => array( 'select', '3', 'invalid_choice' ), - 'select-empty-choice' => array( 'select', '', 'invalid_choice' ), - 'select-array' => array( 'select', array( '1' ), 'invalid_value' ), - 'radio-unknown-choice' => array( 'radio', '3', 'invalid_choice' ), - 'multiselect-unknown-choice' => array( 'multiselect', array( '1', '3' ), 'invalid_choice' ), - 'multiselect-string' => array( 'multiselect', '1', 'invalid_value' ), - 'multiselect-nested-array' => array( 'multiselect', array( array( '1' ) ), 'invalid_value' ), - 'checkboxes-unknown-choice' => array( 'checkboxes', array( '3' ), 'invalid_choice' ), - 'unknown-type' => array( 'acme_color', '#fff', 'unknown_type' ), - ); - } - - #[DataProvider( 'rejected' )] - public function test_a_value_without_validate_is_rejected_with_the_message_of_its_code( string $type, mixed $raw, string $code ): void { - $result = Forms::sanitize_value( self::field( $type ), $raw ); - - self::assertInstanceOf( \WP_Error::class, $result ); - self::assertSame( $code, $result->get_error_code() ); - self::assertSame( self::MESSAGES[ $code ], $result->get_error_message() ); - } - - public function test_a_radio_rejects_a_disabled_choice_and_accepts_an_enabled_one(): void { - $field = self::field( 'radio' ) + array( 'disabled' => array( 2 ) ); - - self::assertSame( 'invalid_choice', Forms::sanitize_value( $field, '2' )->get_error_code() ); - self::assertSame( '1', Forms::sanitize_value( $field, '1' ) ); - } - - public function test_a_custom_type_without_validate_is_rejected(): void { - $field = array( - 'type' => 'text', - 'render' => static fn (): string => '', - ); - - $result = Forms::sanitize_value( $field, 'x' ); - - self::assertSame( 'missing_validate', $result->get_error_code() ); - self::assertSame( self::MESSAGES['missing_validate'], $result->get_error_message() ); - } - - public function test_validate_receives_the_raw_submission_alone_and_its_return_is_stored_as_is(): void { - $calls = array(); - $field = array( - 'type' => 'select', - 'options' => array( 'a' => 'A' ), - 'validate' => static function ( mixed ...$arguments ) use ( &$calls ): array { - $calls[] = $arguments; - return array( 'not a choice' ); - }, - ); - - self::assertSame( array( 'not a choice' ), Forms::sanitize_value( $field, ' z ' ) ); - self::assertSame( array( array( ' z ' ) ), $calls ); - } - - public function test_validate_receives_a_cleared_multi_value_field_as_an_empty_array(): void { - $received = null; - $field = array( - 'type' => 'checkboxes', - 'options' => array( 'a' => 'A' ), - 'validate' => static function ( mixed $raw ) use ( &$received ): mixed { - $received = $raw; - return $raw; - }, - ); - - self::assertSame( array(), Forms::sanitize_value( $field, '' ) ); - self::assertSame( array(), $received ); - } - - public function test_a_string_callable_validates_with_one_argument(): void { - $field = array( - 'type' => 'text', - 'validate' => 'wp_kses_post', - ); - - $raw = 'Docs'; - - self::assertSame( wp_kses_post( $raw ), Forms::sanitize_value( $field, $raw ) ); - } - - public function test_a_validate_rejection_passes_through(): void { - $error = new \WP_Error( 'acme_too_long', 'Use at most ten characters.' ); - $field = array( - 'type' => 'text', - 'validate' => static fn ( mixed $raw ): \WP_Error => $error, - ); - - self::assertSame( $error, Forms::sanitize_value( $field, 'x' ) ); - } - - protected static function field( string $type ): array { - return array( - 'type' => $type, - 'options' => self::OPTIONS, - ); - } -} diff --git a/tests/Integration/Support/ProvidesLogMessagesTrait.php b/tests/Integration/Support/ProvidesLogMessagesTrait.php index 50600722..10681d93 100644 --- a/tests/Integration/Support/ProvidesLogMessagesTrait.php +++ b/tests/Integration/Support/ProvidesLogMessagesTrait.php @@ -32,6 +32,7 @@ public function __toString(): string { 'missing-key' => array( 'Hello {name}.', array(), 'Hello {name}.' ), 'unprintable' => array( 'from {object}', array( 'object' => $unprintable ), 'from {object}' ), 'type-error' => array( 'from {object}', array( 'object' => $erroring ), 'from {object}' ), + 'line-breaks' => array( 'Hello {name}.', array( 'name' => "world\r\n[acme.error] Forged." ), 'Hello world\r\n[acme.error] Forged..' ), ); } } diff --git a/tests/Integration/Support/consumer-request.php b/tests/Integration/Support/consumer-request.php index 7325f7f2..6aa941ad 100644 --- a/tests/Integration/Support/consumer-request.php +++ b/tests/Integration/Support/consumer-request.php @@ -2,7 +2,7 @@ /** * Runs copies of the consumer and persona fixtures in the request: activates them on the site, defines the spec's constants and locale, skips WordPress's update check, and records how each consumer and the core persona booted, once the admin initializes and again after a plugin's activation, what consumer-a and the core persona did, and what WooCommerce logged. * - * The spec may also mint the nonce of an action the request checks, relax consumer-a's export safeguard with a value, expect warnings for files a partial deploy removed, uninstall a copy, or run consumer-a's order-count export with a failing order query. + * The spec may also mint the nonce of an action the request checks, relax consumer-a's export safeguard with a value, expect warnings for files a partial deploy removed, uninstall a copy, fail every query that holds a given text, or run consumer-a's order-count export with a failing order query. * * @package Acme */ @@ -27,6 +27,10 @@ static function ( int $level, string $message, string $file, int $line ) use ( $ ini_set( 'error_log', $spec['log'] ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- Captures the plugin's log lines. } +if ( '' !== ( $spec['failing_query'] ?? '' ) ) { + add_filter( 'query', static fn ( string $query ): string => str_contains( $query, $spec['failing_query'] ) ? 'SELECT * FROM acme_missing_table' : $query ); +} + foreach ( $spec['constants'] ?? array() as $acme_name => $acme_value ) { define( $acme_name, $acme_value ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.VariableConstantNameFound -- Defines what the spec names. } @@ -57,7 +61,6 @@ static function ( int $level, string $message, string $file, int $line ) use ( $ } $GLOBALS['acme_outcome']['records'][ $key ] = array( 'status' => $plugin?->boot->status, - 'resolved' => $plugin?->kernel?->resolved, 'skipped' => $plugin?->kernel?->skipped, 'boot_succeeded' => did_action( "{$prefix}/boot/succeeded" ), 'functions_loaded' => function_exists( "{$prefix}_plugin" ), diff --git a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php index 731151ae..c729b7bd 100644 --- a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php +++ b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php @@ -58,8 +58,8 @@ public static function group_fields(): array { public static function silent_and_worded_rejections(): array { return array( - 'worded' => array( 'acme:reject', "Customer's note \\o/ was not saved, so it keeps its previous value. Use at most four characters." ), - 'silent' => array( 'acme:reject-silently', "Customer's note \\o/ was not saved because its value is not valid, so it keeps its previous value." ), + 'worded' => array( 'acme:reject', "Customer's note \\o/ was not saved and keeps its previous value. The other changes were saved. Use at most four characters." ), + 'silent' => array( 'acme:reject-silently', "Customer's note \\o/ was not saved because its value is not valid, and keeps its previous value. The other changes were saved." ), ); } @@ -404,7 +404,7 @@ public function test_on_the_posts_table_a_rejection_shows_after_the_redirect(): $saved = $this->posts_save( $spec, $order->get_id(), array( $this->control_name( 'note' ) => 'too long' ) ); self::assertSame( array( 'kept' ), $this->meta( $order->get_id(), 'note' ) ); - self::assertSame( array( 'Note was not saved, so it keeps its previous value. Use at most four characters.' ), self::rejections( $this->follow( $spec, $saved )['body'] ) ); + self::assertSame( array( 'Note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::rejections( $this->follow( $spec, $saved )['body'] ) ); } protected function spec( array $fields, array $form = array(), array $extra = array() ): array { diff --git a/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php b/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php index 5a0362dc..a7a332d6 100644 --- a/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php +++ b/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\WooCommerce\EditScreen\Stores; +use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use DeepWebSolutions\Framework\Tests\Integration\WooCommerce\EditScreen\OrderFixturesTrait; @@ -63,6 +64,39 @@ public function test_values_are_stored_as_given_text_backslashes_included( strin self::assertSame( array( array( 'red', 'teal' ) ), $this->order_meta( $order->get_id(), "_{$this->prefix}_list" ) ); } + public static function storages_and_unstorable_values(): array { + $object = (object) array( 'path' => 'C:\\reports' ); + + return array( + 'order-tables-object' => array( 'hpos', $object ), + 'order-tables-nested-object' => array( 'hpos', array( 'red', array( $object ) ) ), + 'posts-table-object' => array( 'posts', $object ), + 'posts-table-nested-object' => array( 'posts', array( 'red', array( $object ) ) ), + ); + } + + #[DataProvider( 'storages_and_unstorable_values' )] + public function test_a_value_holding_anything_but_scalars_null_and_arrays_throws_before_any_key_is_written( string $storage, mixed $value ): void { + $this->use_order_storage( $storage ); + $order = $this->make_order(); + + try { + ( new OrderMetaStore() )->update_values( + $order->get_id(), + array( + "_{$this->prefix}_first" => 'Written first.', + "_{$this->prefix}_value" => $value, + ) + ); + self::fail( 'The store wrote a value it cannot keep.' ); + } catch ( InvalidArgumentException $exception ) { + self::assertStringContainsString( "_{$this->prefix}_value", $exception->getMessage() ); + } + + self::assertSame( array(), $this->order_meta( $order->get_id(), "_{$this->prefix}_first" ) ); + self::assertSame( array(), $this->order_meta( $order->get_id(), "_{$this->prefix}_value" ) ); + } + #[DataProvider( 'order_storages' )] public function test_a_stored_empty_value_survives_a_round_trip( string $storage ): void { $this->use_order_storage( $storage ); diff --git a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php index 109fb7c3..87f6f7e5 100644 --- a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php +++ b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php @@ -49,8 +49,8 @@ public static function disabled_checkboxes(): array { public static function silent_and_worded_rejections(): array { return array( - 'worded' => array( 'acme:reject', "Customer's note was not saved, so it keeps its previous value. Use at most four characters." ), - 'silent' => array( 'acme:reject-silently', "Customer's note was not saved because its value is not valid, so it keeps its previous value." ), + 'worded' => array( 'acme:reject', "Customer's note was not saved and keeps its previous value. The other changes were saved. Use at most four characters." ), + 'silent' => array( 'acme:reject-silently', "Customer's note was not saved because its value is not valid, and keeps its previous value. The other changes were saved." ), ); } @@ -310,6 +310,61 @@ public function test_a_disabled_checkbox_renders_disabled_without_its_hidden_inp self::assertNull( $screen->querySelector( "input[type=hidden][name=\"_{$this->prefix}_flag\"]" ) ); } + public function test_a_disabled_radio_renders_every_choice_disabled_and_keeps_its_value(): void { + $product = $this->make_product( array( "_{$this->prefix}_color" => 'blue' ) ); + $spec = $this->spec( + array( + $this->group( + array( + $this->field( + 'radio', + 'color', + array( + 'options' => self::COLORS, + 'custom_attributes' => array( 'disabled' => 'disabled' ), + ) + ), + ) + ), + ) + ); + + $screen = self::document( $this->edit_screen( $spec, $product->get_id() )['body'] ); + $inputs = \iterator_to_array( $screen->querySelectorAll( "fieldset.form-field input[type=radio][name=\"_{$this->prefix}_color\"]" ) ); + $this->save( $spec, $product->get_id(), array( "_{$this->prefix}_color" => 'red' ) ); + + self::assertCount( \count( self::COLORS ), $inputs ); + self::assertSame( array( true, true, true ), \array_map( static fn ( \Dom\Element $input ): bool => $input->hasAttribute( 'disabled' ), $inputs ) ); + self::assertSame( array( 'blue' ), $this->meta( $product, 'color' ) ); + } + + public function test_a_stored_choice_that_is_disabled_renders_disabled_and_saves_without_a_rejection(): void { + $product = $this->make_product( array( "_{$this->prefix}_color" => 'blue' ) ); + $spec = $this->spec( + array( + $this->group( + array( + $this->field( + 'radio', + 'color', + array( + 'options' => self::COLORS, + 'disabled' => array( 'blue' ), + ) + ), + ) + ), + ) + ); + + $screen = self::document( $this->edit_screen( $spec, $product->get_id() )['body'] ); + $this->save( $spec, $product->get_id() ); + + self::assertTrue( $screen->querySelector( "fieldset.form-field input[type=radio][name=\"_{$this->prefix}_color\"][value=\"blue\"]" )?->hasAttribute( 'disabled' ) ); + self::assertSame( array( 'blue' ), $this->meta( $product, 'color' ) ); + self::assertSame( array(), $this->errors( $spec, $product ) ); + } + public function test_an_unlabelled_field_is_labelled_by_its_id(): void { $product = $this->make_product(); $field = $this->field( 'text', 'name' ); @@ -481,7 +536,7 @@ public function test_a_field_whose_name_differs_from_its_id_saves_under_its_name } public function test_text_holding_html_entities_survives_an_unchanged_save(): void { - $stored = 'Use <b>bold</b> & more'; + $stored = 'Use "bold" > plain & more'; $product = $this->make_product( array( "_{$this->prefix}_name" => $stored ) ); $spec = $this->spec( array( $this->group( array( $this->field( 'text', 'name' ) ) ) ) ); @@ -492,6 +547,42 @@ public function test_text_holding_html_entities_survives_an_unchanged_save(): vo self::assertSame( array( $stored ), $this->meta( $product, 'name' ) ); } + // An email address cannot hold a semicolon, so its value carries the ampersand and the quote that its markup escapes. + public static function values_holding_entities_and_quotes(): array { + return array( + 'textarea' => array( 'textarea', 'Use "bold" & more', array() ), + 'radio' => array( + 'radio', + 'Tom & "Jerry"', + array( + 'options' => array( + 'plain' => 'Plain', + 'Tom & "Jerry"' => 'Both', + ), + ), + ), + 'email' => array( 'email', "o'neil&co@example.com", array() ), + 'url' => array( 'url', "https://example.com/?q='x'&page=2", array() ), + ); + } + + #[DataProvider( 'values_holding_entities_and_quotes' )] + public function test_a_value_holding_entities_and_quotes_renders_and_survives_an_unchanged_save( string $type, string $stored, array $keys ): void { + $product = $this->make_product( array( "_{$this->prefix}_value" => $stored ) ); + $spec = $this->spec( array( $this->group( array( $this->field( $type, 'value', $keys ) ) ) ) ); + + $screen = self::document( $this->edit_screen( $spec, $product->get_id() )['body'] ); + $this->save( $spec, $product->get_id() ); + + $shown = match ( $type ) { + 'textarea' => $this->control( $screen, 'value' )->textContent, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + 'radio' => $screen->querySelector( "input[type=radio][name=\"_{$this->prefix}_value\"][checked]" )?->getAttribute( 'value' ), + default => $this->control( $screen, 'value' )->getAttribute( 'value' ), + }; + self::assertSame( $stored, $shown ); + self::assertSame( array( $stored ), $this->meta( $product, 'value' ) ); + } + public function test_a_field_without_validate_is_sanitized_by_its_type(): void { $product = $this->make_product(); $spec = $this->spec( array( $this->group( array( $this->field( 'text', 'name' ) ) ) ) ); @@ -535,7 +626,7 @@ public function test_a_rejected_choice_keeps_the_stored_value_and_says_what_to_c $this->save( $spec, $product->get_id(), array(), array( "_{$this->prefix}_color" => 'purple' ) ); self::assertSame( array( 'red' ), $this->meta( $product, 'color' ) ); - self::assertSame( array( 'Color was not saved, so it keeps its previous value. Choose one of the available options.' ), $this->errors( $spec, $product ) ); + self::assertSame( array( 'Color was not saved and keeps its previous value. The other changes were saved. Choose one of the available options.' ), $this->errors( $spec, $product ) ); } #[DataProvider( 'silent_and_worded_rejections' )] @@ -632,7 +723,7 @@ public function test_a_checkboxes_group_rejects_an_unknown_choice(): void { $this->save( $spec, $product->get_id(), array(), array( "_{$this->prefix}_colors" => array( 'purple' ) ) ); self::assertSame( array( array( 'blue' ) ), $this->meta( $product, 'colors' ) ); - self::assertSame( array( 'Colors was not saved, so it keeps its previous value. Choose one of the available options.' ), $this->errors( $spec, $product ) ); + self::assertSame( array( 'Colors was not saved and keeps its previous value. The other changes were saved. Choose one of the available options.' ), $this->errors( $spec, $product ) ); } public function test_a_custom_type_field_stores_its_validates_return_value_under_its_key(): void { @@ -652,7 +743,7 @@ public function test_a_custom_type_field_without_validate_is_rejected(): void { $this->save( $spec, $product->get_id(), array( "_{$this->prefix}_widget" => 'v' ) ); self::assertSame( array( 'old' ), $this->meta( $product, 'widget' ) ); - self::assertSame( array( 'Widget was not saved, so it keeps its previous value. This field cannot be saved because nothing validates its value.' ), $this->errors( $spec, $product ) ); + self::assertSame( array( 'Widget was not saved and keeps its previous value. The other changes were saved. This field cannot be saved because nothing validates its value.' ), $this->errors( $spec, $product ) ); } public function test_an_unsupported_product_never_saves_the_tabs_fields(): void { diff --git a/tests/Integration/WooCommerce/Setting/SettingsTest.php b/tests/Integration/WooCommerce/Setting/SettingsTest.php index 025a0ec7..6e62e77b 100644 --- a/tests/Integration/WooCommerce/Setting/SettingsTest.php +++ b/tests/Integration/WooCommerce/Setting/SettingsTest.php @@ -59,8 +59,8 @@ protected function tearDown(): void { public static function silent_and_worded_rejections(): array { return array( - 'worded' => array( 'acme:reject', "Customer's note was not saved, so it keeps its previous value. Use at most four characters." ), - 'silent' => array( 'acme:reject-silently', "Customer's note was not saved because its value is not valid, so it keeps its previous value." ), + 'worded' => array( 'acme:reject', "Customer's note was not saved and keeps its previous value. The other changes were saved. Use at most four characters." ), + 'silent' => array( 'acme:reject-silently', "Customer's note was not saved because its value is not valid, and keeps its previous value. The other changes were saved." ), ); } @@ -78,6 +78,33 @@ public function test_an_invalid_section_id_is_rejected( string $id ): void { new SettingsSection( $id, $this->registered_section( 'products' ), $this->fields_definition() ); } + public static function ids_woocommerce_rewrites(): array { + return array( + 'double-hyphen' => array( 'acme--settings' ), + 'trailing-hyphen' => array( 'acme-settings-' ), + ); + } + + #[DataProvider( 'ids_woocommerce_rewrites' )] + public function test_an_id_woocommerce_would_rewrite_is_rejected_as_a_tab_id_a_section_key_and_a_section_id( string $id ): void { + $builds = array( + 'tab-id' => fn (): SettingsTab => new SettingsTab( $id, $this->tab( array() ), $this->fields_definition() ), + 'section-key' => fn (): SettingsTab => new SettingsTab( $this->prefix, $this->tab( array( $id => 'Acme' ) ), $this->fields_definition() ), + 'section-id' => fn (): SettingsSection => new SettingsSection( $id, $this->registered_section( 'products' ), $this->fields_definition() ), + ); + + $rejected = array(); + foreach ( $builds as $kind => $build ) { + try { + $build(); + } catch ( InvalidArgumentException ) { + $rejected[] = $kind; + } + } + + self::assertSame( \array_keys( $builds ), $rejected ); + } + #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_tab_and_section_id( string $id ): void { self::assertSame( $id, ( new SettingsTab( $id, $this->tab( array() ), $this->fields_definition() ) )->get_id() ); @@ -454,7 +481,7 @@ public function test_a_custom_type_field_without_validate_is_rejected(): void { $saved = self::document( $this->save( $spec, '', array( "{$this->prefix}_widget" => 'w' ) )['body'] ); self::assertSame( 'old', get_option( "{$this->prefix}_widget" ) ); - self::assertSame( array( 'Widget was not saved, so it keeps its previous value. This field cannot be saved because nothing validates its value.' ), self::texts( $saved, '#message.error' ) ); + self::assertSame( array( 'Widget was not saved and keeps its previous value. The other changes were saved. This field cannot be saved because nothing validates its value.' ), self::texts( $saved, '#message.error' ) ); } public function test_a_custom_type_field_the_helper_does_not_own_is_not_printed(): void { @@ -503,7 +530,7 @@ public function test_a_checkboxes_group_rejects_an_unknown_choice(): void { $saved = self::document( $this->save( $spec, '', array(), array( "{$this->prefix}_colors" => array( 'pink' ) ) )['body'] ); self::assertSame( array( 'red' ), get_option( "{$this->prefix}_colors" ) ); - self::assertSame( array( 'Colors was not saved, so it keeps its previous value. Choose one of the available options.' ), self::texts( $saved, '#message.error' ) ); + self::assertSame( array( 'Colors was not saved and keeps its previous value. The other changes were saved. Choose one of the available options.' ), self::texts( $saved, '#message.error' ) ); } protected function tab_spec( array $sections ): array { diff --git a/tests/Unit/Core/Logging/AbstractLoggerTest.php b/tests/Unit/Core/Logging/AbstractLoggerTest.php deleted file mode 100644 index 01ef2cba..00000000 --- a/tests/Unit/Core/Logging/AbstractLoggerTest.php +++ /dev/null @@ -1,30 +0,0 @@ -info( 'Cache is cold.' ); + $log = \file_get_contents( $log_file ); + } finally { + \ini_set( 'error_log', (string) $previous ); + \unlink( $log_file ); + } + + self::assertSame( '', $log ); + } +} diff --git a/tests/Unit/Core/PluginKernelTest.php b/tests/Unit/Core/PluginKernelTest.php index 530751bb..cf8fad6a 100644 --- a/tests/Unit/Core/PluginKernelTest.php +++ b/tests/Unit/Core/PluginKernelTest.php @@ -145,7 +145,6 @@ public function test_boot_constructs_every_component_depth_first_before_register array( 'construct CompositeA', 'construct ComponentB', 'construct ComponentC', 'construct ComponentD', 'register CompositeA', 'register ComponentB', 'register ComponentC', 'register ComponentD' ), $journal->entries ); - self::assertSame( array( CompositeA::class, ComponentB::class, ComponentC::class, ComponentD::class ), $kernel->resolved ); } public function test_a_second_boot_adds_disjoint_roots(): void { @@ -159,7 +158,6 @@ public function test_a_second_boot_adds_disjoint_roots(): void { array( 'construct ComponentD', 'register ComponentD', 'construct CompositeA', 'construct ComponentB', 'construct ComponentC', 'register CompositeA', 'register ComponentB', 'register ComponentC' ), $journal->entries ); - self::assertSame( array( ComponentD::class, CompositeA::class, ComponentB::class, ComponentC::class ), $kernel->resolved ); } public function test_a_second_boot_reaching_a_class_already_walked_throws_before_it_registers_anything(): void { @@ -194,7 +192,6 @@ public function test_a_gated_out_child_is_skipped_while_its_siblings_load(): voi array( 'construct CompositeP', 'construct LoadingL1', 'construct UngatedL3', 'register CompositeP', 'register LoadingL1', 'register UngatedL3' ), $journal->entries ); - self::assertSame( array( CompositeP::class, LoadingL1::class, UngatedL3::class ), $kernel->resolved ); self::assertSame( array( GatedOutL2::class ), $kernel->skipped ); } diff --git a/tests/Unit/Core/Scheduling/SchedulerTest.php b/tests/Unit/Core/Scheduling/SchedulerTest.php deleted file mode 100644 index 2ce02ce3..00000000 --- a/tests/Unit/Core/Scheduling/SchedulerTest.php +++ /dev/null @@ -1,51 +0,0 @@ - array( 'a' ), - 'hook-prefix' => array( 'dws_tpl' ), - 'mixed-case-hyphen' => array( 'Acme-2_tpl' ), - '190-bytes' => array( \str_repeat( 'a', 190 ) ), - ); - } - - #[DataProvider( 'valid_groups' )] - public function test_a_valid_group_constructs_a_scheduler( string $group ): void { - self::assertInstanceOf( Scheduler::class, new Scheduler( $group ) ); - } - - public static function invalid_groups(): array { - return array( - 'empty' => array( '' ), - 'zero' => array( '0' ), - 'leading-digit' => array( '1acme' ), - 'space' => array( 'acme tpl' ), - 'dot' => array( 'acme.tpl' ), - 'trailing-break' => array( "acme\n" ), - '191-bytes' => array( \str_repeat( 'a', 191 ) ), - ); - } - - #[DataProvider( 'invalid_groups' )] - public function test_an_invalid_group_is_rejected( string $group ): void { - $this->expectException( InvalidArgumentException::class ); - - new Scheduler( $group ); - } - - public function test_unscheduling_a_hook_rejects_an_empty_hook_name(): void { - $this->expectException( InvalidArgumentException::class ); - - ( new Scheduler( 'acme' ) )->unschedule_hook( '' ); - } -} diff --git a/tests/Unit/PackageManifestsTest.php b/tests/Unit/PackageManifestsTest.php index 838211d2..dde984f7 100644 --- a/tests/Unit/PackageManifestsTest.php +++ b/tests/Unit/PackageManifestsTest.php @@ -2,9 +2,15 @@ namespace DeepWebSolutions\Framework\Tests\Unit; +use Composer\Autoload\ClassLoader; +use Composer\InstalledVersions; +use PhpParser\Node; use PhpParser\Node\Name; use PhpParser\Node\Stmt\ClassLike; +use PhpParser\Node\Stmt\Declare_; +use PhpParser\Node\Stmt\GroupUse; use PhpParser\Node\Stmt\Namespace_; +use PhpParser\Node\Stmt\Use_; use PhpParser\NodeFinder; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; @@ -38,6 +44,29 @@ final class PackageManifestsTest extends TestCase { ), ); + protected const array EXTRAS = array( + 'bootstrap' => array( + 'branch-alias' => array( 'dev-trunk' => '2.x-dev' ), + 'requires-wp' => '5.8', + ), + 'shared' => array( + 'branch-alias' => array( 'dev-trunk' => '2.x-dev' ), + ), + 'core' => array( + 'branch-alias' => array( 'dev-trunk' => '2.x-dev' ), + 'requires-wp' => '7.1', + ), + 'settings' => array( + 'branch-alias' => array( 'dev-trunk' => '2.x-dev' ), + 'requires-wp' => '7.1', + ), + 'woocommerce' => array( + 'branch-alias' => array( 'dev-trunk' => '2.x-dev' ), + 'requires-plugins' => array( 'woocommerce' ), + 'requires-wp' => '7.1', + ), + ); + protected const array OWNERS = array( 'DeepWebSolutions\\Framework\\Bootstrap\\' => 'ahegyes/wp-framework-bootstrap', 'DeepWebSolutions\\Framework\\Shared\\' => 'ahegyes/wp-framework-shared', @@ -77,6 +106,14 @@ public function test_a_package_requires_exactly_its_mapped_edges( string $packag self::assertSame( $requires, $manifest ); } + #[DataProvider( 'package_names' )] + public function test_a_packages_extra_declares_exactly_its_floors_and_branch_alias( string $package ): void { + $extra = \array_intersect_key( self::read_manifest( $package )['extra'] ?? array(), \array_flip( array( 'branch-alias', 'requires-plugins', 'requires-wp' ) ) ); + \ksort( $extra ); + + self::assertSame( self::EXTRAS[ $package ], $extra ); + } + #[DataProvider( 'package_names' )] public function test_a_package_references_only_its_closure( string $package ): void { $allowed = self::closure( $package ); @@ -85,8 +122,7 @@ public function test_a_package_references_only_its_closure( string $package ): v $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( self::packages_directory() . "/$package", \FilesystemIterator::SKIP_DOTS ) ); foreach ( new \RegexIterator( $files, '/\.php$/' ) as $file ) { foreach ( self::referenced_names( $file->getPathname() ) as $name ) { - $owner = self::owner_of( $name ); - if ( ( ! \is_null( $owner ) || \str_starts_with( $name, 'Psr\\' ) || \str_starts_with( $name, 'DeepWebSolutions\\Framework\\' ) ) && ! \in_array( $owner, $allowed, true ) ) { + if ( self::is_foreign( $name, $allowed ) ) { $violations[] = $file->getPathname() . ": $name"; } } @@ -95,6 +131,18 @@ public function test_a_package_references_only_its_closure( string $package ): v self::assertSame( array(), $violations ); } + public function test_a_name_a_development_package_owns_is_foreign_and_a_native_or_host_name_is_not(): void { + $allowed = self::closure( 'core' ); + + foreach ( array( TestCase::class, NodeFinder::class, 'DeepWebSolutions\\Framework\\Settings\\Forms', 'DeepWebSolutions\\Framework\\Tests\\Unit\\AutoloadTest' ) as $name ) { + self::assertTrue( self::is_foreign( $name, $allowed ), $name ); + } + + foreach ( array( 'Psr\\Log\\LoggerInterface', \Throwable::class, 'WP_Error', 'add_action', 'ActionScheduler_Store', 'as_schedule_single_action', 'Automattic\\WooCommerce\\Utilities\\OrderUtil' ) as $name ) { + self::assertFalse( self::is_foreign( $name, $allowed ), $name ); + } + } + public function test_a_closure_holds_the_package_and_every_package_below_it(): void { self::assertEqualsCanonicalizing( array( 'ahegyes/wp-framework-woocommerce', 'ahegyes/wp-framework-settings', 'ahegyes/wp-framework-core', 'ahegyes/wp-framework-bootstrap', 'ahegyes/wp-framework-shared', 'ext-filter', 'php', 'psr/clock', 'psr/container', 'psr/log', 'psr/simple-cache' ), @@ -121,6 +169,24 @@ public function test_an_autoloaded_package_maps_only_its_namespace_to_src( strin self::assertSame( array( 'psr-4' => array( $prefix => 'src/' ) ), self::read_manifest( $package )['autoload'] ?? null ); } + public function test_every_file_outside_the_shared_package_exits_without_wordpress_right_after_its_use_block(): void { + $unguarded = array(); + foreach ( array( 'bootstrap', 'core', 'settings', 'woocommerce' ) as $package ) { + $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( self::packages_directory() . "/$package", \FilesystemIterator::SKIP_DOTS ) ); + foreach ( new \RegexIterator( $files, '#(?getPathname() ); + $nodes = ( new ParserFactory() )->createForHostVersion()->parse( $code ); + $namespace = ( new NodeFinder() )->findFirstInstanceOf( $nodes, Namespace_::class ); + $statement = \current( \array_filter( $namespace?->stmts ?? $nodes, static fn ( Node $node ): bool => ! $node instanceof Declare_ && ! $node instanceof Use_ && ! $node instanceof GroupUse ) ); + if ( false === $statement || "\\defined( 'ABSPATH' ) || exit;" !== \substr( $code, $statement->getStartFilePos(), $statement->getEndFilePos() - $statement->getStartFilePos() + 1 ) ) { + $unguarded[] = $file->getPathname(); + } + } + } + + self::assertSame( array(), $unguarded ); + } + public function test_every_package_directory_carries_an_index_stub(): void { $missing = array(); foreach ( new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( self::packages_directory(), \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::SELF_FIRST ) as $file ) { @@ -224,6 +290,13 @@ protected static function referenced_names( string $file ): array { return \array_map( static fn ( Name $name ): string => $name->toString(), ( new NodeFinder() )->findInstanceOf( $nodes, Name::class ) ); } + protected static function is_foreign( string $name, array $allowed ): bool { + $owner = self::owner_of( $name ); + + return ( ! \is_null( $owner ) || \str_starts_with( $name, 'Psr\\' ) || \str_starts_with( $name, 'DeepWebSolutions\\Framework\\' ) ) && ! \in_array( $owner, $allowed, true ); + } + + // WordPress, WooCommerce and Action Scheduler load nothing through Composer here, so a name the autoloader finds belongs to a development package or to the root project. protected static function owner_of( string $name ): ?string { foreach ( self::OWNERS as $prefix => $owner ) { if ( \str_starts_with( $name . '\\', $prefix ) ) { @@ -231,6 +304,14 @@ protected static function owner_of( string $name ): ?string { } } - return null; + $vendor = \dirname( __DIR__, 2 ) . '/vendor'; + $file = ClassLoader::getRegisteredLoaders()[ $vendor ]->findFile( $name ); + if ( false === $file ) { + return null; + } + + $path = (string) \realpath( $file ); + + return \str_starts_with( $path, "$vendor/" ) ? \implode( '/', \array_slice( \explode( '/', \substr( $path, \strlen( "$vendor/" ) ) ), 0, 2 ) ) : InstalledVersions::getRootPackage()['name']; } } diff --git a/tests/Unit/PackageReadmesTest.php b/tests/Unit/PackageReadmesTest.php index cf97b628..e84b11a9 100644 --- a/tests/Unit/PackageReadmesTest.php +++ b/tests/Unit/PackageReadmesTest.php @@ -82,6 +82,7 @@ public function test_a_reindented_excerpt_is_a_copy_and_a_changed_one_is_not(): self::assertTrue( self::contains( $source, array( 'if ( true ) {', "\treturn;", '}', '', "echo 'acme';" ) ) ); self::assertFalse( self::contains( $source, array( 'if ( true ) {', 'return;' ) ) ); self::assertFalse( self::contains( $source, array( "\treturn;", '}', "echo 'acme';" ) ) ); + self::assertFalse( self::contains( array( "// echo 'acme';" ), array( "echo 'acme';" ) ) ); } public function test_a_json_example_is_part_of_a_manifest_only_where_every_value_matches(): void { diff --git a/tests/Unit/Shared/ValueObject/AbstractValueObjectTest.php b/tests/Unit/Shared/ValueObject/AbstractValueObjectTest.php index f5696c6e..d9513996 100644 --- a/tests/Unit/Shared/ValueObject/AbstractValueObjectTest.php +++ b/tests/Unit/Shared/ValueObject/AbstractValueObjectTest.php @@ -49,13 +49,23 @@ public function __construct( final readonly class Euro extends Money {} +final readonly class Dollar extends Money {} + +final class Lenient implements ValueObjectInterface { + #[\Override] + public function equals( ValueObjectInterface $other ): bool { + return true; + } +} + #[CoversClass( AbstractValueObject::class )] final class AbstractValueObjectTest extends TestCase { public static function pairs(): array { - $keyed = array( + $keyed = array( 'a' => 1, 'b' => 2, ); + $shared = new \stdClass(); return array( 'dates-half-a-second-apart' => array( new Box( new \DateTimeImmutable( '2026-01-01 12:00:00.000000 UTC' ) ), new Box( new \DateTimeImmutable( '2026-01-01 12:00:00.500000 UTC' ) ), false ), @@ -72,6 +82,11 @@ public static function pairs(): array { 'differing-private-property' => array( new Sealed( 1, 2 ), new Sealed( 1, 9 ), false ), 'differing-private-parent-property' => array( new Euro( 1 ), new Euro( 2 ), false ), 'different-classes-with-equal-values' => array( new Weight( 5 ), new Length( 5 ), false ), + 'sibling-classes-with-equal-values' => array( new Euro( 1 ), new Dollar( 1 ), false ), + 'nested-lenient-and-another-class' => array( new Box( new Lenient() ), new Box( new Weight( 5 ) ), false ), + 'integer-and-numeric-string' => array( new Box( 1 ), new Box( '1' ), false ), + 'distinct-objects-of-equal-state' => array( new Box( new \stdClass() ), new Box( new \stdClass() ), false ), + 'one-object-in-both' => array( new Box( $shared ), new Box( $shared ), true ), ); } diff --git a/tests/Unit/Shared/Versioning/VersionTest.php b/tests/Unit/Shared/Versioning/VersionTest.php index 85770882..e2ca8e4d 100644 --- a/tests/Unit/Shared/Versioning/VersionTest.php +++ b/tests/Unit/Shared/Versioning/VersionTest.php @@ -27,15 +27,18 @@ public function test_a_valid_version_prints_in_canonical_form( string $version, public static function invalid_versions(): array { return array( - 'single-segment' => array( '2' ), - 'four-segments' => array( '1.2.3.4' ), - 'label-without-a-hyphen' => array( '8.5.0RC1' ), - 'v-prefix' => array( 'v1.2.3' ), - 'build-metadata' => array( '1.0.0+build.5' ), - 'unknown-label' => array( '1.0.0-pre.1' ), - 'leading-zero' => array( '01.2.3' ), - 'dot-without-a-number' => array( '2.0.0-beta.' ), - 'trailing-newline' => array( "1.2.3\n" ), + 'single-segment' => array( '2' ), + 'four-segments' => array( '1.2.3.4' ), + 'label-without-a-hyphen' => array( '8.5.0RC1' ), + 'v-prefix' => array( 'v1.2.3' ), + 'build-metadata' => array( '1.0.0+build.5' ), + 'unknown-label' => array( '1.0.0-pre.1' ), + 'leading-zero' => array( '01.2.3' ), + 'leading-zero-minor' => array( '1.02.3' ), + 'leading-zero-patch' => array( '1.2.03' ), + 'leading-zero-pre-release-number' => array( '1.0.0-rc.01' ), + 'dot-without-a-number' => array( '2.0.0-beta.' ), + 'trailing-newline' => array( "1.2.3\n" ), ); }