Since 1.6.0 "Show" both whitelisted a method for its group and hid it from everybody else. A group shown ordinary methods next to a free one therefore took the ordinary ones away from every stranger, who was left with only the free method. "Only" now carries the reservation; "Show" is just the group's whitelist. Existing show rules are converted to only once on update so the checkout does not change until the shop chooses. Co-Authored-By: Claude Code <[email protected]>
870 lines
36 KiB
PHP
870 lines
36 KiB
PHP
<?php
|
|
/**
|
|
* What must still be true after a WordPress or WooCommerce upgrade.
|
|
*
|
|
* Run inside the throwaway site with `wp eval-file`, against real tables, a
|
|
* real cart and real WooCommerce objects. That is the point: a mocked cart
|
|
* keeps passing after WooCommerce has changed underneath it, which is the one
|
|
* failure this whole directory exists to catch.
|
|
*
|
|
* @package WC_Email_Discount_Manager
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/*
|
|
* The counter lives in $GLOBALS on purpose, and the four lines below prove it
|
|
* reaches the verdict before anything relies on it.
|
|
*
|
|
* `wp eval-file` runs this file through eval() INSIDE A METHOD, so the top
|
|
* level here is function scope, not global scope. A plain `$failures = 0` at
|
|
* the top and a `global $failures` in a helper below are therefore two
|
|
* different variables: the helpers increment the real global, the verdict at
|
|
* the bottom reads the local, and the local is always zero. Every assertion
|
|
* fails, every FAIL line prints, the file ends with "All assertions passed"
|
|
* and exits 0 — and CI goes green. That is not reasoning, it is what this file
|
|
* did until it was measured.
|
|
*
|
|
* This is the one file whose entire job is to be able to go red, so it checks
|
|
* that it can.
|
|
*/
|
|
$GLOBALS['wc_edm_failures'] = 0;
|
|
$GLOBALS['wc_edm_passes'] = 0;
|
|
( static function () { ++$GLOBALS['wc_edm_failures']; } )();
|
|
if ( 1 !== $GLOBALS['wc_edm_failures'] ) {
|
|
echo "The failure counter does not reach the verdict: this file cannot go red.\n";
|
|
exit( 1 );
|
|
}
|
|
$GLOBALS['wc_edm_failures'] = 0;
|
|
|
|
/**
|
|
* Assert a condition and keep going, so one run reports every failure.
|
|
*
|
|
* @param string $what What is being checked.
|
|
* @param bool $condition Result of the check.
|
|
* @param string $detail Shown when it fails.
|
|
*/
|
|
function wc_edm_ok( $what, $condition, $detail = '' ) {
|
|
if ( $condition ) {
|
|
++$GLOBALS['wc_edm_passes'];
|
|
printf( " ok %s\n", $what );
|
|
return;
|
|
}
|
|
|
|
++$GLOBALS['wc_edm_failures'];
|
|
printf( " FAIL %s%s\n", $what, '' !== $detail ? " — {$detail}" : '' );
|
|
}
|
|
|
|
/**
|
|
* Assert two values are equal.
|
|
*
|
|
* @param string $what What is being checked.
|
|
* @param mixed $expected Expected value.
|
|
* @param mixed $actual Actual value.
|
|
*/
|
|
function wc_edm_is( $what, $expected, $actual ) {
|
|
wc_edm_ok(
|
|
$what,
|
|
$expected === $actual,
|
|
sprintf( 'expected %s, got %s', var_export( $expected, true ), var_export( $actual, true ) )
|
|
);
|
|
}
|
|
|
|
$fixtures = get_option( 'wc_edm_ci_fixtures' );
|
|
wc_edm_ok( 'fixtures were created', is_array( $fixtures ) );
|
|
|
|
/* ---------------------------------------------------------------- schema -- */
|
|
|
|
global $wpdb;
|
|
foreach ( array( 'groups', 'group_categories', 'emails', 'shipping_rules', 'audit_log' ) as $short ) {
|
|
$table = WC_EDM_DB::table( $short );
|
|
wc_edm_ok(
|
|
"table {$short} exists",
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
|
|
$wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table,
|
|
'activation should have created it via dbDelta()'
|
|
);
|
|
}
|
|
|
|
wc_edm_is(
|
|
'the recorded DB version matches the plugin',
|
|
WC_EDM_VERSION,
|
|
get_option( 'wc_edm_db_version' )
|
|
);
|
|
|
|
/* -------------------------------------------------------------- matching -- */
|
|
|
|
$match = WC_EDM_Helpers::find_email_match( '[email protected]' );
|
|
wc_edm_is( 'an exact pattern beats the domain pattern that also matches', 'custom', $match ? $match->assignment_type : null );
|
|
|
|
$match = WC_EDM_Helpers::find_email_match( '[email protected]' );
|
|
wc_edm_is( 'a domain pattern matches everyone on the domain', 'group', $match ? $match->assignment_type : null );
|
|
|
|
$match = WC_EDM_Helpers::find_email_match( '[email protected]' );
|
|
wc_edm_is( 'a partial pattern matches anywhere in the address', '5.00', $match ? $match->custom_discount : null );
|
|
|
|
wc_edm_ok( 'an unknown address matches nothing', null === WC_EDM_Helpers::find_email_match( '[email protected]' ) );
|
|
wc_edm_ok( 'a deactivated assignment is ignored', null === WC_EDM_Helpers::find_email_match( '[email protected]' ) );
|
|
wc_edm_ok( 'a malformed address matches nothing', null === WC_EDM_Helpers::find_email_match( 'not-an-email' ) );
|
|
|
|
/* ------------------------------------------------------------------ cart -- */
|
|
|
|
// wc_load_cart() is WooCommerce's own way of getting a session, a customer and
|
|
// a cart outside a web request. Without it WC()->cart is null in WP-CLI and
|
|
// every cart assertion below would quietly skip.
|
|
wc_load_cart();
|
|
wc_edm_ok( 'WooCommerce gives us a cart outside a web request', WC()->cart instanceof WC_Cart );
|
|
|
|
/**
|
|
* Empty the cart, set who the customer is, and recalculate.
|
|
*
|
|
* @param string $email Billing email.
|
|
* @param array $products product_id => quantity.
|
|
*/
|
|
function wc_edm_cart_for( $email, $products ) {
|
|
WC()->cart->empty_cart();
|
|
WC()->cart->remove_coupons();
|
|
WC()->session->set( WC_EDM_Frontend::SESSION_EMAIL_KEY, null );
|
|
WC()->customer->set_billing_email( $email );
|
|
|
|
foreach ( $products as $product_id => $quantity ) {
|
|
WC()->cart->add_to_cart( $product_id, $quantity );
|
|
}
|
|
|
|
WC()->cart->calculate_totals();
|
|
}
|
|
|
|
/**
|
|
* Total of the negative fees on the cart, as a positive number.
|
|
*
|
|
* @return float
|
|
*/
|
|
function wc_edm_discount_total() {
|
|
$total = 0;
|
|
foreach ( WC()->cart->get_fees() as $fee ) {
|
|
if ( $fee->amount < 0 ) {
|
|
$total += -$fee->amount;
|
|
}
|
|
}
|
|
return round( $total, 2 );
|
|
}
|
|
|
|
wc_edm_cart_for( '[email protected]', array( $fixtures['product_in'] => 1, $fixtures['product_out'] => 1 ) );
|
|
wc_edm_is(
|
|
'a group discount takes 20% off the eligible item only, not the whole cart',
|
|
20.0,
|
|
wc_edm_discount_total()
|
|
);
|
|
|
|
wc_edm_cart_for( '[email protected]', array( $fixtures['product_out'] => 1 ) );
|
|
wc_edm_is( 'a cart with nothing in the group gets no discount', 0.0, wc_edm_discount_total() );
|
|
|
|
wc_edm_cart_for( '[email protected]', array( $fixtures['product_in'] => 1, $fixtures['product_out'] => 1 ) );
|
|
wc_edm_is( 'a custom percent applies to the whole cart', 75.0, wc_edm_discount_total() );
|
|
|
|
wc_edm_cart_for( '[email protected]', array( $fixtures['product_in'] => 1 ) );
|
|
wc_edm_is( 'an unknown customer pays full price', 0.0, wc_edm_discount_total() );
|
|
|
|
wc_edm_cart_for( '[email protected]', array( $fixtures['product_in'] => 1 ) );
|
|
wc_edm_ok(
|
|
'a coupon assignment applies the coupon',
|
|
in_array( 'ciflat10', WC()->cart->get_applied_coupons(), true ),
|
|
'applied: ' . implode( ', ', WC()->cart->get_applied_coupons() )
|
|
);
|
|
wc_edm_is( 'and the coupon actually discounts the cart', 10.0, round( WC()->cart->get_discount_total(), 2 ) );
|
|
|
|
/* -------------------------------------------------------------- shipping -- */
|
|
|
|
WC_EDM_DB::replace_shipping_rules(
|
|
(int) $fixtures['group_id'],
|
|
// The admin stores the zone *instance* key ("flat_rate:1"), not the method
|
|
// id, and the filter compares against the rate id — which is the same
|
|
// shape. A rule holding a bare method id silently matches nothing, and the
|
|
// "never leave the customer without shipping" fallback then hands back
|
|
// every rate, so the misconfiguration looks exactly like no rule at all.
|
|
array( array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'only', 'sort_order' => 0 ) )
|
|
);
|
|
|
|
$rates = array(
|
|
'flat_rate:1' => new WC_Shipping_Rate( 'flat_rate:1', 'Flat rate', 10, array(), 'flat_rate' ),
|
|
'local_pickup:2' => new WC_Shipping_Rate( 'local_pickup:2', 'Local pickup', 0, array(), 'local_pickup' ),
|
|
);
|
|
|
|
WC()->customer->set_billing_email( '[email protected]' );
|
|
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
|
|
wc_edm_is( 'an "only" rule hides every method it does not name', array( 'flat_rate:1' ), array_keys( $filtered ) );
|
|
|
|
// 🔴 This used to assert "an unmatched customer sees every method", which is
|
|
// what made a reserved free-delivery method visible to everybody on a live
|
|
// shop. A "show" rule now means the method belongs to that group.
|
|
WC()->customer->set_billing_email( '[email protected]' );
|
|
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
|
|
wc_edm_is(
|
|
'an unmatched customer does not see a method a group reserved',
|
|
array( 'local_pickup:2' ),
|
|
array_keys( $filtered )
|
|
);
|
|
|
|
// The guard that keeps the fix from being worse than the leak: a shop that
|
|
// reserved everything it offers must still let an ordinary customer check out.
|
|
WC_EDM_DB::replace_shipping_rules(
|
|
(int) $fixtures['group_id'],
|
|
array(
|
|
array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'only', 'sort_order' => 0 ),
|
|
array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'only', 'sort_order' => 1 ),
|
|
)
|
|
);
|
|
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
|
|
wc_edm_is( 'but is never left with no shipping at all', 2, count( $filtered ) );
|
|
|
|
WC_EDM_DB::replace_shipping_rules(
|
|
(int) $fixtures['group_id'],
|
|
array( array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'only', 'sort_order' => 0 ) )
|
|
);
|
|
|
|
// A SECOND group, with no rules of its own. Its members must not see what the
|
|
// first group reserved — the leak was reported for customers matching nothing,
|
|
// but it existed for this shape too, and for a group carrying only "hide"
|
|
// rules, because neither engages the whitelist.
|
|
$wc_edm_other = WC_EDM_DB::insert_group( array( 'name' => 'Ruleless', 'discount_percent' => 5 ) );
|
|
WC_EDM_DB::insert_email(
|
|
array(
|
|
'email_pattern' => '[email protected]',
|
|
'assignment_type' => 'group',
|
|
'group_id' => (int) $wc_edm_other,
|
|
'coupon_code' => null,
|
|
'custom_discount' => null,
|
|
'is_active' => 1,
|
|
'free_shipping' => 0,
|
|
)
|
|
);
|
|
|
|
WC()->customer->set_billing_email( '[email protected]' );
|
|
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
|
|
wc_edm_is(
|
|
'a group with no rules still does not see another group reserved method',
|
|
array( 'local_pickup:2' ),
|
|
array_keys( $filtered )
|
|
);
|
|
|
|
// A third rate, deliberately: with only two, hiding one and reserving the
|
|
// other leaves nothing, the "never leave the customer without shipping" guard
|
|
// fires, and every rate comes back — which reads as a leak but is the guard
|
|
// working. The fixture has to leave something for the customer to be given.
|
|
$wc_edm_rates3 = $rates;
|
|
$wc_edm_rates3['flat_rate:9'] = new WC_Shipping_Rate( 'flat_rate:9', 'Another flat rate', 12, array(), 'flat_rate' );
|
|
|
|
WC_EDM_DB::replace_shipping_rules(
|
|
(int) $wc_edm_other,
|
|
array( array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'hide', 'sort_order' => 0 ) )
|
|
);
|
|
$filtered = wc_edm()->frontend->filter_shipping_rates( $wc_edm_rates3, array() );
|
|
wc_edm_ok(
|
|
'and neither does a group carrying only hide rules',
|
|
! in_array( 'flat_rate:1', array_keys( $filtered ), true ),
|
|
'saw: ' . implode( ', ', array_keys( $filtered ) )
|
|
);
|
|
|
|
|
|
// 🔴 "show" is a whitelist for the group and reserves NOTHING. 1.6.0 made it
|
|
// do both, so a shop that listed ordinary methods next to a free-delivery one
|
|
// took the ordinary ones away from every stranger and left them only the
|
|
// free one (reported live: strangers saw nothing but "Fri levering").
|
|
WC_EDM_DB::replace_shipping_rules(
|
|
(int) $fixtures['group_id'],
|
|
array(
|
|
array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'show', 'sort_order' => 0 ),
|
|
array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'only', 'sort_order' => 1 ),
|
|
)
|
|
);
|
|
WC()->customer->set_billing_email( '[email protected]' );
|
|
$filtered = wc_edm()->frontend->filter_shipping_rates( $wc_edm_rates3, array() );
|
|
wc_edm_is(
|
|
'a stranger keeps a "show" method and loses the "only" one',
|
|
array( 'flat_rate:1', 'flat_rate:9' ),
|
|
array_keys( $filtered )
|
|
);
|
|
WC()->customer->set_billing_email( '[email protected]' );
|
|
$filtered = wc_edm()->frontend->filter_shipping_rates( $wc_edm_rates3, array() );
|
|
wc_edm_is(
|
|
'a group member sees both its "show" and its "only" method, in rule order',
|
|
array( 'flat_rate:1', 'local_pickup:2' ),
|
|
array_keys( $filtered )
|
|
);
|
|
|
|
// The update from 1.6.x: rows written as "show" were reserved, and must stay so.
|
|
global $wpdb;
|
|
$wc_edm_table = WC_EDM_DB::table( 'shipping_rules' );
|
|
WC_EDM_DB::replace_shipping_rules( (int) $fixtures['group_id'], array() );
|
|
$wpdb->insert( $wc_edm_table, array( 'group_id' => (int) $fixtures['group_id'], 'shipping_method_id' => 'local_pickup:2', 'action' => 'show', 'sort_order' => 0 ) );
|
|
delete_option( 'wc_edm_show_to_only_done' );
|
|
WC_EDM_DB::create_tables();
|
|
wc_edm_is(
|
|
'an update turns every old "show" rule into "only"',
|
|
array( 'only' ),
|
|
$wpdb->get_col( $wpdb->prepare( "SELECT action FROM {$wc_edm_table} WHERE group_id = %d", (int) $fixtures['group_id'] ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
);
|
|
$wpdb->update( $wc_edm_table, array( 'action' => 'show' ), array( 'shipping_method_id' => 'local_pickup:2' ) );
|
|
WC_EDM_DB::create_tables();
|
|
wc_edm_is(
|
|
'and does it once only: a later "show" stays "show"',
|
|
array( 'show' ),
|
|
$wpdb->get_col( $wpdb->prepare( "SELECT action FROM {$wc_edm_table} WHERE group_id = %d", (int) $fixtures['group_id'] ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
);
|
|
|
|
WC()->customer->set_billing_email( '[email protected]' );
|
|
|
|
WC_EDM_DB::replace_shipping_rules( (int) $fixtures['group_id'], array() );
|
|
|
|
/* ------------------------------------------------------------------ rest -- */
|
|
|
|
$routes = rest_get_server()->get_routes();
|
|
$ours = array_filter(
|
|
array_keys( $routes ),
|
|
static function ( $route ) {
|
|
return 0 === strpos( $route, '/' . WC_EDM_REST_API::REST_NAMESPACE );
|
|
}
|
|
);
|
|
wc_edm_ok( 'the REST routes are registered', count( $ours ) > 0, 'namespace ' . WC_EDM_REST_API::REST_NAMESPACE );
|
|
|
|
// The namespace spelled out, NOT compared to the constant it came from. The
|
|
// filter above derives what it looks for from REST_NAMESPACE, so it agrees
|
|
// with itself whatever that constant says: rename it and the routes are still
|
|
// "registered", while every caller that already integrated against
|
|
// wc-edm/v1 gets a 404 and nothing here goes red. This is the one string in
|
|
// the plugin that somebody else's code depends on by name.
|
|
wc_edm_is(
|
|
'and under the namespace callers already integrated against',
|
|
'wc-edm/v1',
|
|
WC_EDM_REST_API::REST_NAMESPACE
|
|
);
|
|
|
|
$request = new WP_REST_Request( 'GET', '/' . WC_EDM_REST_API::REST_NAMESPACE . '/groups' );
|
|
$response = rest_get_server()->dispatch( $request );
|
|
wc_edm_is( 'and they refuse an unauthenticated caller', 401, $response->get_status() );
|
|
|
|
/* -------------------------------------------------------------- nolimit -- */
|
|
|
|
// This plugin claims the shared parent menu's label. Asserted through the
|
|
// filter rather than by reading the menu, because the menu is only built in an
|
|
// admin request and this runs under WP-CLI.
|
|
wc_edm_is(
|
|
'the shared menu is labelled Nolimit',
|
|
'Nolimit',
|
|
apply_filters( 'wpmgr_menu_title', 'Manager' )
|
|
);
|
|
|
|
// The filter above proves this plugin CLAIMS the label. It does not prove the
|
|
// page is registered under that parent, and those are different failures: the
|
|
// menu said Nolimit while this plugin was still absent from it.
|
|
//
|
|
// Loaded and invoked by hand because the admin menu is only built in an admin
|
|
// request and these run under WP-CLI, where is_admin() is false and
|
|
// class-admin.php is therefore never included. That makes this a reduced
|
|
// repro, so it asserts the loading half separately below rather than trusting
|
|
// that the by-hand path matches the real one.
|
|
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
|
require_once WC_EDM_PLUGIN_DIR . 'includes/shared-menu.php';
|
|
require_once WC_EDM_PLUGIN_DIR . 'includes/class-admin.php';
|
|
|
|
wp_set_current_user( 1 );
|
|
global $menu, $submenu, $admin_page_hooks;
|
|
$menu = array();
|
|
$submenu = array();
|
|
$admin_page_hooks = array();
|
|
|
|
$wc_edm_admin = new WC_EDM_Admin();
|
|
$wc_edm_admin->register_menu();
|
|
|
|
wc_edm_ok(
|
|
'the parent Nolimit menu exists',
|
|
isset( $admin_page_hooks[ WPMGR_MENU_SLUG ] ),
|
|
'wpmgr_ensure_menu() did not register it'
|
|
);
|
|
|
|
$wc_edm_pages = isset( $submenu[ WPMGR_MENU_SLUG ] )
|
|
? wp_list_pluck( $submenu[ WPMGR_MENU_SLUG ], 2 )
|
|
: array();
|
|
wc_edm_ok(
|
|
'and this plugin has a page under it',
|
|
in_array( WC_EDM_Admin::PAGE_SLUG, $wc_edm_pages, true ),
|
|
'pages found: ' . ( $wc_edm_pages ? implode( ', ', $wc_edm_pages ) : 'none' )
|
|
);
|
|
|
|
// The other half: the by-hand require above would pass even if the plugin
|
|
// never loaded the file in a real admin request. This is the assertion that
|
|
// covers that, and it is the half a reduced repro always omits.
|
|
//
|
|
// Read from the list the plugin actually loads from, NOT searched for in this
|
|
// file's source text. The previous version did the latter, and a comment
|
|
// mentioning that path would have satisfied it — the same hollow shape as
|
|
// grepping a file for the words of the guard it is supposed to have, which is
|
|
// a bug this kit has already had once today.
|
|
wc_edm_ok(
|
|
'and the plugin itself loads the shared menu file in admin',
|
|
in_array( 'includes/shared-menu.php', WC_Email_Discount_Manager::admin_includes(), true ),
|
|
'admin includes: ' . implode( ', ', WC_Email_Discount_Manager::admin_includes() )
|
|
);
|
|
|
|
// The card on the shared dashboard. Absent from it is a different failure from
|
|
// absent from the menu, and Kristian hit both in turn: the menu entry missing,
|
|
// then the card missing once the entry was there.
|
|
$wc_edm_cards = apply_filters( 'wpmgr_dashboard_cards', array() );
|
|
$wc_edm_mine = array_values(
|
|
array_filter(
|
|
$wc_edm_cards,
|
|
static function ( $card ) {
|
|
return isset( $card['url'] ) && false !== strpos( $card['url'], WC_EDM_Admin::PAGE_SLUG );
|
|
}
|
|
)
|
|
);
|
|
wc_edm_ok(
|
|
'this plugin puts a card on the shared dashboard',
|
|
1 === count( $wc_edm_mine ),
|
|
count( $wc_edm_cards ) . ' card(s) registered in total'
|
|
);
|
|
|
|
// Matched against the literal rather than the constant: the shared file
|
|
// renders any colour it is handed, so a card asserting its own constant would
|
|
// agree with itself after somebody put WooCommerce purple back.
|
|
wc_edm_is(
|
|
'and it carries this plugin colour, not WooCommerce purple',
|
|
'#1a7a52',
|
|
isset( $wc_edm_mine[0]['colour'] ) ? $wc_edm_mine[0]['colour'] : null
|
|
);
|
|
|
|
// 🔴 The style function is INVOKED, not read. A version of this file shipped
|
|
// with `width:100%` inside a printf format string, where a literal per-cent
|
|
// must be doubled — an uncaught ValueError on every admin page. Three sessions
|
|
// read it, php -l was clean, PHPCS was clean, and two browsers agreed about
|
|
// the CSS, because all of them were fed the stylesheet by hand rather than
|
|
// through the PHP that produces it. Only running it finds this.
|
|
if ( function_exists( 'wpmgr_menu_glyph_style' ) ) {
|
|
$wc_edm_threw = '';
|
|
ob_start();
|
|
try {
|
|
wpmgr_menu_glyph_style();
|
|
} catch ( \Throwable $e ) {
|
|
$wc_edm_threw = get_class( $e ) . ': ' . $e->getMessage();
|
|
}
|
|
$wc_edm_style = ob_get_clean();
|
|
|
|
wc_edm_ok(
|
|
'emitting the menu glyph style throws nothing',
|
|
'' === $wc_edm_threw,
|
|
$wc_edm_threw
|
|
);
|
|
wc_edm_ok(
|
|
'and what it emits is a style element carrying the glyph',
|
|
false !== strpos( $wc_edm_style, '<style>' )
|
|
&& false !== strpos( $wc_edm_style, '</style>' )
|
|
&& false !== strpos( $wc_edm_style, '\\221E' ),
|
|
'emitted: ' . substr( $wc_edm_style, 0, 120 )
|
|
);
|
|
} else {
|
|
echo "::warning::wpmgr_menu_glyph_style() is not defined, so the glyph style was not exercised.\n";
|
|
}
|
|
|
|
// 🔴 The dashboard is RENDERED and the pill colour read out of what it
|
|
// emitted. Asserting the colour alone is not enough: the renderer returns
|
|
// early when no card is registered, and a harness that stubs the wrong filter
|
|
// name then reports "no problem" over a code path it never entered. Three
|
|
// sessions hit exactly that. So the card's own title is asserted first — if
|
|
// that is absent the renderer bailed, and nothing after it means anything.
|
|
if ( function_exists( 'wpmgr_render_dashboard' ) ) {
|
|
ob_start();
|
|
wpmgr_render_dashboard();
|
|
$wc_edm_dash = ob_get_clean();
|
|
|
|
wc_edm_ok(
|
|
'the shared dashboard renders this plugin card',
|
|
false !== strpos( $wc_edm_dash, 'Discounts' )
|
|
&& false !== strpos( $wc_edm_dash, 'wpmgr-card' ),
|
|
'rendered ' . strlen( $wc_edm_dash ) . ' bytes'
|
|
);
|
|
|
|
// The status pill is white text composited over the card colour, so 22%
|
|
// white put this plugin's green at 3.50:1. Named as the literal the file
|
|
// must contain, and the OLD value is asserted absent — a check that cannot
|
|
// tell the two versions apart cannot testify about either.
|
|
wc_edm_ok(
|
|
'and the status pill is the contrast-safe background',
|
|
false !== strpos( $wc_edm_dash, 'rgba(0,0,0,.18)' )
|
|
&& false === strpos( $wc_edm_dash, 'rgba(255,255,255,.22)' ),
|
|
'pill rule in output: ' . ( false !== strpos( $wc_edm_dash, 'rgba(255,255,255,.22)' ) ? 'the old 22% white' : 'not found at all' )
|
|
);
|
|
}
|
|
|
|
// The frozen hash of includes/shared-menu.php is asserted in ci/static.sh
|
|
// against WPMGR_MENU_SHA in ci/plugin.conf, and deliberately NOT repeated
|
|
// here: two copies of the same constant drift, and the one that drifts is the
|
|
// one nobody remembers to update. What is worth asserting at runtime is a
|
|
// different fact — that the file is actually loaded in a real install, which
|
|
// the parent-menu assertion above already proves, since wpmgr_ensure_menu()
|
|
// only exists if it was.
|
|
|
|
/* ------------------------------------------------------------ woocommerce -- */
|
|
|
|
$compatible = \Automattic\WooCommerce\Utilities\FeaturesUtil::get_compatible_plugins_for_feature( 'custom_order_tables' );
|
|
wc_edm_ok(
|
|
'HPOS compatibility is declared',
|
|
in_array( plugin_basename( WC_EDM_PLUGIN_FILE ), $compatible['compatible'], true ),
|
|
'without the declaration WooCommerce shows this plugin as incompatible with High-Performance Order Storage in wp-admin'
|
|
);
|
|
|
|
/* --------------------------------------------------------------- updates -- */
|
|
|
|
// get_plugin_data() lives in the admin includes, which are not loaded in a
|
|
// WP-CLI context by default.
|
|
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
|
|
|
$update_uri = WC_EDM_Updater::update_uri();
|
|
wc_edm_ok(
|
|
'the plugin header names an update source',
|
|
'' !== $update_uri && 0 === strpos( $update_uri, 'https://' ),
|
|
'without "Update URI:" WordPress offers no updates at all — and worse, it falls back to asking wordpress.org about this slug'
|
|
);
|
|
|
|
wc_edm_ok(
|
|
'the update filter is hooked for that host',
|
|
has_filter( 'update_plugins_' . wp_parse_url( $update_uri, PHP_URL_HOST ) ) !== false,
|
|
'the filter name is built from the host in the header, so a typo in either silently disables updates'
|
|
);
|
|
|
|
// The API address is derived from the header rather than configured, so a
|
|
// change to either has to keep producing a reachable endpoint.
|
|
$derive = new ReflectionMethod( 'WC_EDM_Updater', 'api_url' );
|
|
$derive->setAccessible( true );
|
|
wc_edm_is(
|
|
'the release API address is derived from the repository address',
|
|
'https://gitea.nolimit.dk/api/v1/repos/kw/demo/releases/latest',
|
|
$derive->invoke( new WC_EDM_Updater(), 'https://gitea.nolimit.dk/kw/demo' )
|
|
);
|
|
|
|
// The forge builds browser_download_url from its own configured root URL,
|
|
// which is not necessarily the address the outside world reaches it on. This
|
|
// is the real response gitea.nolimit.dk returned for v1.4.0: a plain-HTTP raw
|
|
// IP that bypasses the Cloudflare tunnel in front of it. A shop handed that
|
|
// would be fetching plugin code over an unencrypted connection to an address
|
|
// that is free to change, so the download address is rebuilt from the
|
|
// repository address in the plugin header instead.
|
|
$parse = new ReflectionMethod( 'WC_EDM_Updater', 'parse_release' );
|
|
$parse->setAccessible( true );
|
|
$parsed = $parse->invoke(
|
|
new WC_EDM_Updater(),
|
|
array(
|
|
'tag_name' => 'v1.4.0',
|
|
'body' => "notes\n\nTested up to: 7.1\nWC tested up to: 11.1.0\n",
|
|
'html_url' => 'http://57.129.89.117:25001/kw/woocommerce-email-discount-manager/releases/tag/v1.4.0',
|
|
'assets' => array(
|
|
array(
|
|
'name' => 'woocommerce-email-discount-manager.zip',
|
|
'browser_download_url' => 'http://57.129.89.117:25001/kw/woocommerce-email-discount-manager/releases/download/v1.4.0/woocommerce-email-discount-manager.zip',
|
|
),
|
|
),
|
|
)
|
|
);
|
|
|
|
wc_edm_is( 'the version comes from the tag, without the v', '1.4.0', $parsed['version'] );
|
|
wc_edm_is(
|
|
'the download address is rebuilt on the host in the plugin header',
|
|
$update_uri . '/releases/download/v1.4.0/woocommerce-email-discount-manager.zip',
|
|
$parsed['package']
|
|
);
|
|
// Asserted as the host, not as the absence of one particular IP address.
|
|
// Gitea's ROOT_URL has been a raw address before, and the package URL it
|
|
// reports carries whatever that is — so a check that names today's IP is blind
|
|
// to tomorrow's. What matters is that the URL points at the host we publish
|
|
// from, however the forge chose to describe itself.
|
|
$wc_edm_host = wp_parse_url( $parsed['package'], PHP_URL_HOST );
|
|
wc_edm_is(
|
|
'so it is never the raw address the forge happened to report',
|
|
'gitea.nolimit.dk',
|
|
$wc_edm_host
|
|
);
|
|
wc_edm_is(
|
|
'and it is fetched over https',
|
|
'https',
|
|
wp_parse_url( $parsed['package'], PHP_URL_SCHEME )
|
|
);
|
|
wc_edm_is( 'and the tested-up-to line is read out of the release notes', '7.1', $parsed['tested'] );
|
|
wc_edm_ok( 'a release with no zip attached is not offered at all', null === $parse->invoke( new WC_EDM_Updater(), array( 'tag_name' => 'v9.9.9', 'assets' => array() ) ) );
|
|
|
|
// Feed the updater a known release through its own cache instead of the
|
|
// network: what matters here is the shape handed to WordPress, and a test
|
|
// that needs the internet fails for reasons that have nothing to do with it.
|
|
set_transient(
|
|
WC_EDM_Updater::TRANSIENT,
|
|
array(
|
|
'version' => '99.0.0',
|
|
'package' => 'https://gitea.nolimit.dk/kw/demo/releases/download/v99.0.0/demo.zip',
|
|
'url' => 'https://gitea.nolimit.dk/kw/demo/releases/tag/v99.0.0',
|
|
'tested' => '7.1',
|
|
),
|
|
60
|
|
);
|
|
|
|
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
|
wc_edm_is( 'a newer release is offered as an update', '99.0.0', is_array( $offered ) ? $offered['version'] : null );
|
|
wc_edm_is(
|
|
'and it carries the folder name WordPress will unpack into',
|
|
'woocommerce-email-discount-manager',
|
|
is_array( $offered ) ? $offered['slug'] : null
|
|
);
|
|
// Dashboard → Updates falls back to a generic grey plug when this is missing,
|
|
// and a 404 here is invisible until someone looks at that screen.
|
|
wc_edm_ok(
|
|
'the offer carries an icon',
|
|
is_array( $offered ) && ! empty( $offered['icons']['svg'] ),
|
|
'without it wp-admin shows the generic plugin placeholder'
|
|
);
|
|
wc_edm_ok(
|
|
'and the icon is a file that is actually in the release',
|
|
file_exists( WC_EDM_PLUGIN_DIR . 'assets/icon.svg' ),
|
|
'the offer would point wp-admin at a 404'
|
|
);
|
|
|
|
wc_edm_ok(
|
|
'the offer names a downloadable package',
|
|
is_array( $offered ) && ! empty( $offered['package'] ),
|
|
'an offer without a package is an update WordPress cannot install'
|
|
);
|
|
|
|
// The same version already installed must still be answered, not skipped:
|
|
// WordPress files that away under no_update, and that list is what the
|
|
// per-plugin "Enable auto-updates" link on the plugins screen is built from.
|
|
set_transient(
|
|
WC_EDM_Updater::TRANSIENT,
|
|
array(
|
|
'version' => WC_EDM_VERSION,
|
|
'package' => 'https://gitea.nolimit.dk/kw/demo/releases/download/v1/demo.zip',
|
|
'url' => 'https://gitea.nolimit.dk/kw/demo',
|
|
'tested' => '7.1',
|
|
),
|
|
60
|
|
);
|
|
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
|
wc_edm_is( 'the current version is answered too, not passed over', WC_EDM_VERSION, is_array( $offered ) ? $offered['version'] : null );
|
|
|
|
// And the case neither assertion above covers: the feed could not be reached.
|
|
// Returning $update untouched there drops the plugin out of BOTH the response
|
|
// and no_update lists, and WP_Plugins_List_Table builds the per-plugin "Enable
|
|
// auto-updates" link from that transient — so the row reads "Auto-updates are
|
|
// not available for this plugin", which looks like a setting rather than a
|
|
// failed HTTP request. The shop then silently stops being offered fixes.
|
|
set_transient( WC_EDM_Updater::TRANSIENT, 'none', 60 );
|
|
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
|
wc_edm_is(
|
|
'an unreachable feed still answers with the installed version',
|
|
WC_EDM_VERSION,
|
|
is_array( $offered ) ? $offered['version'] : null
|
|
);
|
|
wc_edm_ok(
|
|
'and that answer still carries the icon',
|
|
is_array( $offered ) && ! empty( $offered['icons'] ),
|
|
'without icons in this branch Dashboard -> Updates shows the grey default plug until the feed answers again'
|
|
);
|
|
delete_transient( WC_EDM_Updater::TRANSIENT );
|
|
|
|
// 🔴 The filter name is built from the HOSTNAME, not the repository. Every
|
|
// plugin updating from gitea.nolimit.dk shares the hook
|
|
// update_plugins_gitea.nolimit.dk, and WordPress runs every callback on it for
|
|
// every one of those plugins. Answering unconditionally means offering the
|
|
// neighbour your own release — which happened on a live shop: a subscriptions
|
|
// plugin was shown this one's 1.4.4 and told it was an update for itself. Load
|
|
// order decides who wins, so the direction it breaks in is arbitrary.
|
|
//
|
|
// The site test installs exactly one plugin from this host, so the collision
|
|
// can never occur here. This assertion is the whole defence.
|
|
wc_edm_ok(
|
|
'another plugin on the same host is not offered our release',
|
|
false === wc_edm()->updater->offer_update(
|
|
false,
|
|
get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ),
|
|
'kw-subscriptions/kw-subscriptions.php'
|
|
),
|
|
'we answered for a plugin that is not ours'
|
|
);
|
|
|
|
// The "View version details" link next to the update notice. WordPress builds
|
|
// it as plugin-install.php?tab=plugin-information&plugin=<slug>, which calls
|
|
// plugins_api() — and plugins_api() asks wordpress.org, where this plugin does
|
|
// not exist. Unanswered, that link opens a modal reading "Plugin not found."
|
|
// on the same screen that is telling the shop owner an update is ready.
|
|
// Measured on a live shop before this was written.
|
|
set_transient(
|
|
WC_EDM_Updater::TRANSIENT,
|
|
array(
|
|
'version' => '99.0.0',
|
|
'package' => 'https://gitea.nolimit.dk/kw/demo/releases/download/v99.0.0/demo.zip',
|
|
'url' => 'https://gitea.nolimit.dk/kw/demo',
|
|
'tested' => '7.1',
|
|
'notes' => "v99.0.0 — a headline\n\nA line of release notes.",
|
|
),
|
|
600
|
|
);
|
|
|
|
// plugins_api() lives in the admin includes, which a WP-CLI context does not
|
|
// load. wp-admin has it; this does not.
|
|
require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
|
|
|
|
$wc_edm_slug = dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
|
$details = plugins_api( 'plugin_information', array( 'slug' => $wc_edm_slug ) );
|
|
|
|
wc_edm_ok(
|
|
'the details modal is answered here, not by wordpress.org',
|
|
! is_wp_error( $details ),
|
|
is_wp_error( $details ) ? $details->get_error_message() : ''
|
|
);
|
|
wc_edm_is( 'and it is about this plugin', $wc_edm_slug, is_wp_error( $details ) ? null : $details->slug );
|
|
wc_edm_is( 'it reports the version from the feed', '99.0.0', is_wp_error( $details ) ? null : $details->version );
|
|
wc_edm_ok(
|
|
'the changelog is the release notes',
|
|
! is_wp_error( $details ) && false !== strpos( $details->sections['changelog'], 'A line of release notes' ),
|
|
'a modal with an empty changelog is barely better than an error'
|
|
);
|
|
wc_edm_ok(
|
|
'and the notes are escaped rather than rendered',
|
|
! is_wp_error( $details ) && false === strpos( $details->sections['changelog'], '<script' ),
|
|
'a release body is not a template language'
|
|
);
|
|
wc_edm_ok(
|
|
'another plugin\'s details request is left alone',
|
|
false === wc_edm()->updater->plugin_details( false, 'plugin_information', (object) array( 'slug' => 'akismet' ) ),
|
|
'answering for somebody else would replace their modal with ours'
|
|
);
|
|
|
|
delete_transient( WC_EDM_Updater::TRANSIENT );
|
|
|
|
/*
|
|
* The "Enable auto-updates" link on the Plugins screen.
|
|
*
|
|
* It is not automatic for a plugin that does not come from wordpress.org.
|
|
* WP_Plugins_List_Table sets 'update-supported' => true only for plugins it
|
|
* finds in the update transient's `response` list (an update is waiting) or
|
|
* its `no_update` list (we asked, there is none). A plugin in neither gets
|
|
* "Auto-updates are not available for this plugin." and no toggle.
|
|
*
|
|
* That is the whole reason offer_update() answers even when the installed
|
|
* version is already current. Staying silent there is how a plugin ends up
|
|
* unable to auto-update while looking perfectly configured — so this asserts
|
|
* the outcome, not the intention.
|
|
*
|
|
* The feed stays stubbed through the transient above: this is about what
|
|
* WordPress does with our answer, and a test that needs the internet fails for
|
|
* reasons that have nothing to do with it.
|
|
*/
|
|
// The cache must not outlive WordPress's own check cadence. It gates by screen
|
|
// — one minute on Dashboard → Updates, one hour on the Plugins screen — and
|
|
// this filter only runs when that check runs, so anything longer here hides a
|
|
// new release underneath a cache the shop owner cannot see. This was six hours
|
|
// and produced exactly that: a release published, and a shop shown nothing.
|
|
wc_edm_ok(
|
|
'the cache is shorter than the interval WordPress re-checks on',
|
|
WC_EDM_Updater::TTL <= 15 * MINUTE_IN_SECONDS,
|
|
'TTL is ' . WC_EDM_Updater::TTL . 's; WordPress re-checks every 60s on Dashboard → Updates'
|
|
);
|
|
|
|
// "Check again" must discard the cache, or the button lies: someone
|
|
// who has just published a release clicks it, sees nothing, and concludes the
|
|
// whole mechanism is broken.
|
|
set_transient( WC_EDM_Updater::TRANSIENT, array( 'version' => '1.0.0', 'package' => 'x', 'url' => 'x', 'tested' => '' ), 600 );
|
|
wp_set_current_user( 1 );
|
|
$_GET['force-check'] = '1';
|
|
wc_edm()->updater->forget_on_force_check();
|
|
unset( $_GET['force-check'] );
|
|
wc_edm_ok( 'Check again discards the cached feed', false === get_transient( WC_EDM_Updater::TRANSIENT ) );
|
|
|
|
// And a visitor with no business updating plugins does not get to clear it.
|
|
set_transient( WC_EDM_Updater::TRANSIENT, array( 'version' => '1.0.0', 'package' => 'x', 'url' => 'x', 'tested' => '' ), 600 );
|
|
wp_set_current_user( 0 );
|
|
$_GET['force-check'] = '1';
|
|
wc_edm()->updater->forget_on_force_check();
|
|
unset( $_GET['force-check'] );
|
|
wc_edm_ok( 'but a caller who cannot update plugins does not clear it', is_array( get_transient( WC_EDM_Updater::TRANSIENT ) ) );
|
|
delete_transient( WC_EDM_Updater::TRANSIENT );
|
|
|
|
// Rendering the list table in a CLI context makes core itself emit a
|
|
// strlen(null) deprecation from class-wp-plugins-list-table.php. It is
|
|
// WordPress's, not ours, which is why the deprecation check filters on this
|
|
// plugin's directory rather than failing on everything it sees.
|
|
// Primed again deliberately. The force-check assertions above deleted it, and
|
|
// without this offer_update() makes a real HTTP request — which passes only
|
|
// because this plugin's feed repo happens to be public. That would make the
|
|
// assertion measure the environment rather than the code under test.
|
|
set_transient(
|
|
WC_EDM_Updater::TRANSIENT,
|
|
array(
|
|
'version' => WC_EDM_VERSION,
|
|
'package' => 'https://gitea.nolimit.dk/kw/demo/releases/download/v1/demo.zip',
|
|
'url' => 'https://gitea.nolimit.dk/kw/demo',
|
|
'tested' => '7.1',
|
|
),
|
|
600
|
|
);
|
|
|
|
delete_site_transient( 'update_plugins' );
|
|
wp_update_plugins();
|
|
$transient = get_site_transient( 'update_plugins' );
|
|
$basename = plugin_basename( WC_EDM_PLUGIN_FILE );
|
|
|
|
wc_edm_ok(
|
|
'WordPress files the plugin under response or no_update',
|
|
isset( $transient->response[ $basename ] ) || isset( $transient->no_update[ $basename ] ),
|
|
'in neither, so wp-admin would say "Auto-updates are not available for this plugin"'
|
|
);
|
|
|
|
// And the list table itself, which is what the shop owner actually looks at.
|
|
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
|
|
require_once ABSPATH . 'wp-admin/includes/class-wp-plugins-list-table.php';
|
|
wp_set_current_user( 1 );
|
|
set_current_screen( 'plugins' );
|
|
|
|
$table = new WP_Plugins_List_Table( array( 'screen' => get_current_screen() ) );
|
|
$table->prepare_items();
|
|
|
|
$row = null;
|
|
foreach ( $table->items as $file => $data ) {
|
|
if ( $file === $basename ) {
|
|
$row = $data;
|
|
}
|
|
}
|
|
|
|
wc_edm_ok( 'the plugin is on the Plugins screen at all', null !== $row );
|
|
wc_edm_ok(
|
|
'and WordPress marks it as supporting updates',
|
|
$row && ! empty( $row['update-supported'] ),
|
|
'without this the auto-update column reads "Auto-updates are not available for this plugin."'
|
|
);
|
|
|
|
delete_transient( WC_EDM_Updater::TRANSIENT );
|
|
|
|
/* --------------------------------------------------------------- verdict -- */
|
|
|
|
echo "\n";
|
|
if ( $GLOBALS['wc_edm_failures'] > 0 ) {
|
|
printf( "%d assertion(s) failed on WordPress %s.\n", $GLOBALS['wc_edm_failures'], get_bloginfo( 'version' ) );
|
|
exit( 1 );
|
|
}
|
|
// The number, not just the word. "All assertions passed" is also what a file
|
|
// that asserted nothing prints — which is the failure this whole suite exists
|
|
// to catch everywhere else, and it was in the summary line the entire time.
|
|
// A floor as well, for the same reason every count in ci/ has one: a run that
|
|
// died early after a handful of assertions would otherwise read as a pass.
|
|
if ( $GLOBALS['wc_edm_passes'] < 60 ) {
|
|
printf(
|
|
"Only %d assertions ran, which is too few to be a complete run.\n",
|
|
$GLOBALS['wc_edm_passes']
|
|
);
|
|
exit( 1 );
|
|
}
|
|
|
|
printf(
|
|
"All %d assertions passed on WordPress %s with WooCommerce %s.\n",
|
|
$GLOBALS['wc_edm_passes'],
|
|
get_bloginfo( 'version' ),
|
|
defined( 'WC_VERSION' ) ? WC_VERSION : '?'
|
|
);
|