The v1.4.0 release went out and the feed came back readable, with this in it:
"browser_download_url":
"http://57.129.89.117:25001/kw/woocommerce-email-discount-manager/..."
That field is filled from the forge's own configured root URL, which is not
necessarily the address the outside world reaches it on. A shop handed that
would be downloading plugin code over plain HTTP from a raw IP on a
non-standard port, bypassing the Cloudflare tunnel in front of gitea.nolimit.dk
and breaking the day that address changes.
parse_release() now rebuilds both the package and the release-page address from
the repository address in the plugin header, which is the one place that
already had to be right for any of this to work. tests/smoke.php pins it with
the exact response the live feed returned.
The release check had the same bug from the other end, and a worse version of
it: it verified the address the API reported rather than the one the plugin
builds, so it was a green check on a URL no shop ever uses. It now downloads
the package the plugin would download and opens the zip to confirm it is the
plugin and not an error page with a 200 on it.
And it only runs on the forge named in "Update URI". The other one is a mirror;
requiring a mirror to be anonymously readable failed the GitHub half of the
v1.4.0 release for a repository that is private on purpose.
ci/update-smoke.sh is the check none of the above can stand in for: install the
previous version into a throwaway shop and let WordPress find, download and
install the new one by itself. Every piece can be individually correct and the
update still not arrive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
331 lines
13 KiB
PHP
331 lines
13 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;
|
|
}
|
|
|
|
$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 = '' ) {
|
|
global $wc_edm_failures;
|
|
|
|
if ( $condition ) {
|
|
printf( " ok %s\n", $what );
|
|
return;
|
|
}
|
|
|
|
++$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( 'exact@company.com' );
|
|
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( 'someone@company.com' );
|
|
wc_edm_is( 'a domain pattern matches everyone on the domain', 'group', $match ? $match->assignment_type : null );
|
|
|
|
$match = WC_EDM_Helpers::find_email_match( 'partialperson@elsewhere.test' );
|
|
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( 'nobody@nowhere.test' ) );
|
|
wc_edm_ok( 'a deactivated assignment is ignored', null === WC_EDM_Helpers::find_email_match( 'switched-off@company.dk' ) );
|
|
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( 'someone@company.com', 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( 'someone@company.com', 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( 'exact@company.com', 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( 'nobody@nowhere.test', array( $fixtures['product_in'] => 1 ) );
|
|
wc_edm_is( 'an unknown customer pays full price', 0.0, wc_edm_discount_total() );
|
|
|
|
wc_edm_cart_for( 'buyer@coupons.test', 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' => 'show', '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( 'someone@company.com' );
|
|
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
|
|
wc_edm_is( 'a "show" rule hides every method it does not name', array( 'flat_rate:1' ), array_keys( $filtered ) );
|
|
|
|
WC()->customer->set_billing_email( 'nobody@nowhere.test' );
|
|
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
|
|
wc_edm_is( 'an unmatched customer sees every method', 2, count( $filtered ) );
|
|
|
|
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 );
|
|
|
|
$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() );
|
|
|
|
/* ------------------------------------------------------------ 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']
|
|
);
|
|
wc_edm_ok(
|
|
'so it is never the raw address the forge happened to report',
|
|
false === strpos( $parsed['package'], '57.129.89.117' ) && 0 === strpos( $parsed['package'], 'https://' ),
|
|
'got ' . $parsed['package']
|
|
);
|
|
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 ) );
|
|
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
|
|
);
|
|
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 ) );
|
|
wc_edm_is( 'the current version is answered too, not passed over', WC_EDM_VERSION, is_array( $offered ) ? $offered['version'] : null );
|
|
|
|
delete_transient( WC_EDM_Updater::TRANSIENT );
|
|
|
|
/* --------------------------------------------------------------- verdict -- */
|
|
|
|
echo "\n";
|
|
if ( $wc_edm_failures > 0 ) {
|
|
printf( "%d assertion(s) failed on WordPress %s.\n", $wc_edm_failures, get_bloginfo( 'version' ) );
|
|
exit( 1 );
|
|
}
|
|
printf(
|
|
"All assertions passed on WordPress %s with WooCommerce %s.\n",
|
|
get_bloginfo( 'version' ),
|
|
defined( 'WC_VERSION' ) ? WC_VERSION : '?'
|
|
);
|