Add host PHPUnit unit suite; refactor Query and Connectors for testability (XWPENG-42) - #1972
Add host PHPUnit unit suite; refactor Query and Connectors for testability (XWPENG-42)#1972shadyvb wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a host-native PHPUnit suite and refactors connector loading and query construction for testability.
Changes:
- Adds Brain Monkey unit testing and supporting commands/configuration.
- Refactors connector discovery and metadata APIs.
- Splits query construction into testable SQL-fragment methods.
Critical issues
- Meta sorting generates invalid SQL because
GROUP BYfollowsORDER BY. Swap the clauses. WP VIP ref: valid$wpdbquery construction.
Medium/Low issues
- Documentation references a nonexistent npm script; use
test:php-unit.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
classes/class-query.php |
Refactors query fragments and meta sorting. |
classes/class-connectors.php |
Refactors connector loading and accessors. |
classes/class-connector.php |
Makes label methods abstract. |
abilities/class-ability-get-connectors.php |
Uses the new connector metadata API. |
abilities/class-ability-create-exclusion-rule.php |
Uses the new slug API. |
tests/phpunit/unit/test-query.php |
Tests query fragments and assembly. |
tests/phpunit/unit/test-connectors.php |
Tests connector lifecycle behavior. |
tests/phpunit/unit/bootstrap.php |
Bootstraps host unit tests. |
phpunit-unit.xml |
Configures the unit suite. |
phpunit.xml |
Excludes unit tests from integration runs. |
phpunit-multisite.xml |
Excludes unit tests from multisite runs. |
composer.json |
Adds test dependencies and command. |
composer.lock |
Locks new testing dependencies. |
package.json |
Adds the Docker unit-test wrapper. |
contributing.md |
Documents unit-test commands. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
bfdfaaf to
8e0b76b
Compare
…ad/register pipeline. Register gates and get_all(include_inactive) can now run without booting WordPress, so abilities see admin-only connectors on REST. Query stays the original single query() method.
…tion, and meta ordering can be verified without a full DB. Add host PHPUnit coverage for the Query family and preserve intentional sort and filter-args behavior documented in tests.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
8e0b76b to
38fbc0a
Compare
bartoszgadomski
left a comment
There was a problem hiding this comment.
@shadyvb Thank you for working on this PR! Please check AI-assisted inline comments below.
| $out = array(); | ||
| public function get_all( $include_inactive = false ) { | ||
| $out = array(); | ||
| $connectors = $include_inactive ? (array) $this->connector_instances : (array) $this->connectors; |
There was a problem hiding this comment.
Behaviour change: abilities now expose connectors whose dependencies aren't satisfied.
get_all( true ) and get_slugs( true ) read $connector_instances, which holds every instantiated builtin plus filter extras, with no is_dependency_satisfied() gate. The removed build_full_connector_classes() did skip unsatisfied connectors.
So on a site where WooCommerce / Jetpack / EDD aren't active, stream/get-connectors now lists those connectors and stream/create-exclusion-rule accepts their slugs — Connector_Woocommerce::is_dependency_satisfied() returns false without WC, and previously that removed it from both ability payloads.
test_get_slugs_and_get_all (include_inactive case) locks the new behaviour in, so this looks deliberate rather than accidental. If it is, the ability description should say the list is "connectors this plugin ships" rather than "what exists on this site"; if not, apply the dependency gate in the $include_inactive branch the way the old helper did.
| $limits = $this->limits( $args ); | ||
| $orderby = $this->orderby( $args ); | ||
| $select = $this->select( $args ); | ||
| $groupby = $join ? "GROUP BY $wpdb->stream.ID" : ''; |
There was a problem hiding this comment.
Meta sort picks an arbitrary value from the group.
With GROUP BY stream.ID and ORDER BY streammeta.meta_value, the sort key is an arbitrary row from each group, so records with more than one row for the same meta_key sort on the wrong value. stream_meta has no unique key on (record_id, meta_key), so duplicates are possible.
Reproduced on MySQL 8.4 with this exact generated SQL: record 1 has meta values 5 and 9, record 2 has 7. Result order was 2, 1, 3 — record 1 sorted on 5 instead of 9, so it landed below record 2.
ORDER BY MAX( streammeta.meta_value ) (and MAX( CAST( ... AS SIGNED ) ) for meta_value_num) fixes the ordering and, as a bonus, makes the statement valid under ONLY_FULL_GROUP_BY. As written it's ERROR 1055 under that mode; it only runs because wpdb::set_sql_mode() strips ONLY_FULL_GROUP_BY from its session, which is re-addable via the incompatible_sql_modes filter.
Worth noting the coverage gap too: test_query_meta_orderby_includes_join only string-matches the JOIN and ORDER BY; nothing asserts the GROUP BY or runs the SQL against a real DB. This path isn't reachable from the admin UI or the abilities layer today (meta_key isn't in DB::get_records() defaults and meta_value isn't in the get-records orderby enum), so it would break on the first caller that passes both args.
| ? $this->plugin->connectors->get_all_slugs_including_admin_only() | ||
| : array(); | ||
| if ( ! empty( $known ) && ! in_array( $sanitized['connector'], $known, true ) ) { | ||
| $known = $this->plugin->connectors->get_slugs( true ); |
There was a problem hiding this comment.
This drops two fail-open guards: isset( $this->plugin->connectors ) and ! empty( $known ).
Plugin::$connectors is declared without a default and only assigned in the init callback, so a direct execute() before that point now fatals on a method call on null instead of skipping validation. And if the slug list ever comes back empty — e.g. a wp_stream_connectors filter returning array() — every connector is now rejected rather than validation being bypassed.
Both are edge cases, but the empty-list check in particular seems worth keeping unless failing closed is the intent.
| * @param string[] $class_names Fully-qualified class names. | ||
| * @return array<string, Connector> | ||
| */ | ||
| private function instantiate_connector_classes( $class_names ) { |
There was a problem hiding this comment.
Nit: $class_names is ignored whenever $connector_instances is already an array, so the parameter is only honoured on the first call — an easy trap for a later caller passing a different list and getting the memoized one back.
Related: load_connectors() assigns the post-filter set back to $connector_instances, so a second load_connectors() call re-applies wp_stream_connectors to an already-filtered list and permanently drops any builtin a filter removed. Only the constructor calls it today, so this is latent. Reading the memo inside the method instead of taking it as a parameter would make the contract clearer.
| * @param mixed $unused Existing callers pass the DB driver; ignored. | ||
| */ | ||
| public function __construct( $unused = null ) { | ||
| unset( $unused ); |
There was a problem hiding this comment.
Nit: unset( $unused ) is a no-op on a by-value parameter, so this line can go. Keeping the parameter itself makes sense for BC — DB_Driver_WPDB still calls new Query( $this ) — but naming it $unused and discarding it hides that a driver is still being passed in. Either keep the DB_Driver $driver name (documented as unused) or store it.
| "php": "7.4" | ||
| }, | ||
| "plugin-api-version": "2.9.0" | ||
| "plugin-api-version": "2.6.0" |
There was a problem hiding this comment.
Nit: plugin-api-version goes 2.9.0 → 2.6.0, meaning this lock was regenerated with an older Composer than the one that produced the committed file. Unrelated churn in a dependency PR — re-running the lock update on the project's Composer version would keep this diff limited to the Brain Monkey / Mockery / wp-test-utils additions.
Fixes XWPENG-42.
Introduces a fast, host-native PHPUnit unit tier (
composer test-unit) using Brain Monkey, then refactorsConnectorsandQueryinto testable, named SQL-fragment methods. Along the way, several latent bugs inQuery::where_in(), pagination, meta ordering, and filter-arg handling are fixed and locked in with data-provider tests.Summary
Host unit suite
phpunit-unit.xml,tests/phpunit/unit/bootstrap.php(Composer autoload +WP_Stream\class autoload mirroringPlugin::autoload).composer test-unitscript; dev dependencyyoast/wp-test-utils(Brain Monkey).phpunit.xml,phpunit-multisite.xml) excludetests/phpunit/unit/so Docker/integration runs stay unchanged.contributing.mddocumentscomposer test-unit(host) and Docker wrappernpm run test:php-unit.npm run testrunstest:php-unitas a separate step beforetest:php; unit tests are not merged into the integration PHPUnit config.Connectors refactor
Connectors::BUILTIN_CONNECTOR_SLUGSconstant replaces inline slug list.get_available_connectors()→instantiate_connector_classes()→register_connector_instances().get_slugs( $include_inactive = false )andget_all( $include_inactive = false )replaceget_all_including_admin_only()/get_all_slugs_including_admin_only().get_slugs( true ); get-connectors usesget_all( true )behind new filterwp_stream_abilities_connectors.Connector:get_label(),get_context_labels(),get_action_labels()promoted to abstract (all 22 built-in connectors already implement them).Query refactor
query()split into public fragment methods:where_columns(),where_dates(),where_in(),select(),orderby(),join(),limits().ORDERABLE_FIELDSconstant documents intentional sort behavior.$wpdbproperty set in constructor (enables partial mocks in unit tests).join()emitsLEFT JOIN streammetawhenorderbyismeta_value/meta_value_numandmeta_keyis set.GROUP BY stream.IDon the items query; count query usesCOUNT( DISTINCT stream.ID ).Bugs fixed in original
Querycodewhere_insingle-element arraysarray_shift()removed the only value, thenempty( $value )skipped the clause entirelyarray_values(); one-elementIN (1)worksrecord__infield mappingstr_replace( 'record_', … )left_insuffix → invalid columnrecord→IDmappingwhere_inprepare() with array arg$wpdb->prepare( …, $field, $value )passed the whole array as one scalar → empty/wrong SQL...$valueswith per-value%d/%splaceholders__not_infamilyarray_shift/ prepare bugs__inand__not_inpaged = 0paginationabsint(0)→ offset(0-1)*per_pagewrapped to large positive offset (page 2 behavior)$page < 1clamped to1→LIMIT 0, per_pageORDER BY streammeta.meta_valuewith no JOIN (broken SQL)join()addsLEFT JOIN streammetawhen meta sort +meta_keyGROUP BY stream.ID+COUNT( DISTINCT stream.ID )user_roleandconnector$argsdate leakdatemutated caller args withdate_from/date_tobefore filters ranwhere_dates()expands on a local copy only; filter hooks receive original argsNot restored (intentional): bare
__in/__not_inargs no longer map toID; userecord__ininstead.Intentional behavior preserved
orderby = dateandorderby = ipfall back to sorting byID.records_per_page = 0still emitsLIMIT 0, 0.Breaking changes
Connectorget_all_including_admin_only()methods removedget_all( true )/get_slugs( true )$argsno longer receivedate_from/date_towhen onlydatepassedTesting
Command:
composer test-unit→ OK (53 tests, 106 assertions)test-connectors.phptest-query.phpAccepted gaps: Connectors constructor/include loop; Query __construct, empty search_field fallback, invalid IP, date boundaries.
Gotchas
composer test-unitis host fast path; Docker usesnpm run test:php-unit.contributing.mdreferencesnpm run test-unitbut package.json script istest:php-unit.Commits
Checklist
contributing.md).Release Changelog
Query::where_in()silently dropped single-value filters, mis-mappedrecord__in, and produced invalid SQL with array prepare args.Querypagination treatedpaged = 0as large positive offset.streammetawithout JOIN; duplicate rows deduplicated via GROUP BY / COUNT(DISTINCT).datearg expansion no longer mutates filter-hook$args.composer test-unit).Connectors::get_slugs()/get_all()with$include_inactive.wp_stream_abilities_connectorsfilter.Release Checklist
masterbranch.readme.txt.stream.php.Stable taginreadme.txt.classes/class-plugin.php.