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
%s
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.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" ), ); }