Files
nolimit-discounts/tests/smoke.php
kristianwindandClaude Opus 5 2b289c6763 1.6.2: WP 7.1.2 and WC 11.1.2 declared, and the caller counts the assertions
Kristian asked the family to check WordPress 7.1.2. Run pinned rather than
against "latest", because latest drifts and then nobody knows which version a
green belonged to:

  PHP 7.4   69 assertions, 0 failures   "none from this plugin"
  PHP 8.3   69 assertions, 0 failures   "none from this plugin"
  PHP 8.4   69 assertions, 0 failures   "none from this plugin"

The container reported WordPress 7.1.2 and WooCommerce 11.1.2 on all three,
read out of the run rather than from the variable it was given. So there is no
problem with 7.1.2 here.

The findings were in the reporting, not the code.

The headers were stale: "Tested up to: 7.1" and "WC tested up to: 11.1.0"
against current 7.1.2 and 11.1.2. The WooCommerce one is the visible half — a
shop on 11.1.2 sees an untested warning on the WooCommerce status page, and on
this plugin that banner sits in front of somebody deciding whether to update a
discount engine.

And the suite said "All assertions passed" without a number, which is the exact
failure every other check here was fixed for. It now prints the count.

🔴 Then the floor for that count found a bigger hole than it was written for.
Putting it at the end of the smoke file does nothing: wp eval-file runs the
file in function scope, so a `return` partway through skips everything after
it, including the floor. Measured — truncating tests/smoke.php before the
shipping section ran two thirds of the assertions and ci/site-test.sh reported
GREEN.

A file cannot assert about its own early exit. ci/site-test.sh now captures the
output and checks the total against MIN_SITE_ASSERTIONS from outside, where an
early return cannot reach, and fails when the summary line is missing
altogether. The in-file floor stays for a short run that does reach the end.

Proven with the same fixture that exposed it: the truncated suite now exits 1
saying it printed no assertion total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 08:51:46 +02:00

823 lines
34 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( '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 ) );
// 🔴 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( 'nobody@nowhere.test' );
$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' => 'show', 'sort_order' => 0 ),
array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'show', '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' => 'show', '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' => 'ruleless@elsewhere.test',
'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( 'ruleless@elsewhere.test' );
$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 ) )
);
WC()->customer->set_billing_email( 'someone@company.com' );
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 : '?'
);