2026-09-16 10:34:38 +02:00
<? 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 ;
}
2026-09-16 13:18:22 +02:00
/*
* 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 ;
( 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 ;
2026-09-16 10:34:38 +02:00
/**
* 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 ) {
printf ( " ok %s \n " , $what );
return ;
}
2026-09-16 13:18:22 +02:00
++ $GLOBALS [ 'wc_edm_failures' ];
2026-09-16 10:34:38 +02:00
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 );
2026-09-17 10:29:40 +02:00
// 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
);
2026-09-16 10:34:38 +02:00
$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 () );
2026-09-17 22:05:19 +02:00
/* -------------------------------------------------------------- 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' )
);
2026-09-20 07:52:01 +02:00
// 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.
2026-09-20 10:20:17 +02:00
//
// 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.
2026-09-20 07:52:01 +02:00
wc_edm_ok (
'and the plugin itself loads the shared menu file in admin' ,
2026-09-20 10:20:17 +02:00
in_array ( 'includes/shared-menu.php' , WC_Email_Discount_Manager :: admin_includes (), true ),
'admin includes: ' . implode ( ', ' , WC_Email_Discount_Manager :: admin_includes () )
2026-09-20 07:52:01 +02:00
);
2026-09-20 07:57:02 +02:00
// 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
);
2026-09-20 09:31:37 +02:00
// 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.
2026-09-17 22:05:19 +02:00
2026-09-16 10:34:38 +02:00
/* ------------------------------------------------------------ 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' )
);
2026-09-16 10:49:52 +02:00
// 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\n Tested up to: 7.1 \n WC 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' ]
);
2026-09-20 10:20:17 +02:00
// 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 (
2026-09-16 10:49:52 +02:00
'so it is never the raw address the forge happened to report' ,
2026-09-20 10:20:17 +02:00
'gitea.nolimit.dk' ,
$wc_edm_host
);
wc_edm_is (
'and it is fetched over https' ,
'https' ,
wp_parse_url ( $parsed [ 'package' ], PHP_URL_SCHEME )
2026-09-16 10:49:52 +02:00
);
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 () ) ) );
2026-09-16 10:34:38 +02:00
// 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
);
2026-09-16 14:54:08 +02:00
$offered = wc_edm () -> updater -> offer_update ( false , get_plugin_data ( WC_EDM_PLUGIN_FILE , false , false ), plugin_basename ( WC_EDM_PLUGIN_FILE ) );
2026-09-16 10:34:38 +02:00
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
);
2026-09-16 12:23:05 +02:00
// 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'
);
2026-09-16 10:34:38 +02:00
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
);
2026-09-16 14:54:08 +02:00
$offered = wc_edm () -> updater -> offer_update ( false , get_plugin_data ( WC_EDM_PLUGIN_FILE , false , false ), plugin_basename ( WC_EDM_PLUGIN_FILE ) );
2026-09-16 10:34:38 +02:00
wc_edm_is ( 'the current version is answered too, not passed over' , WC_EDM_VERSION , is_array ( $offered ) ? $offered [ 'version' ] : null );
2026-09-17 13:26:12 +02:00
// 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 );
2026-09-16 14:54:08 +02:00
// 🔴 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'
);
2026-09-16 14:00:21 +02:00
// 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\n A 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 );
2026-09-16 12:23:05 +02:00
/*
* 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.
*/
2026-09-16 13:18:22 +02:00
// 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
2026-09-16 12:23:05 +02:00
// 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.
2026-09-16 13:18:22 +02:00
// 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
);
2026-09-16 12:23:05 +02:00
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."'
);
2026-09-16 10:34:38 +02:00
delete_transient ( WC_EDM_Updater :: TRANSIENT );
/* --------------------------------------------------------------- verdict -- */
echo " \n " ;
2026-09-16 13:18:22 +02:00
if ( $GLOBALS [ 'wc_edm_failures' ] > 0 ) {
printf ( "%d assertion(s) failed on WordPress %s. \n " , $GLOBALS [ 'wc_edm_failures' ], get_bloginfo ( 'version' ) );
2026-09-16 10:34:38 +02:00
exit ( 1 );
}
printf (
"All assertions passed on WordPress %s with WooCommerce %s. \n " ,
get_bloginfo ( 'version' ),
defined ( 'WC_VERSION' ) ? WC_VERSION : '?'
);