Files
nolimit-discounts/tests/smoke.php
T
kristianwindandClaude Opus 5 a1f6e54f9d The smoke test could not fail, and other people's eyes
Four fixes, two of them serious, three of them found by the session porting
this into subscriptions.

1. THE SMOKE TEST COULD NOT FAIL. `wp eval-file` runs the file through eval()
   INSIDE A METHOD, so the top level is function scope. `$wc_edm_failures = 0`
   at the top and `global $wc_edm_failures` in the helper were two different
   variables: the helpers incremented the real global, the verdict read the
   local, and the local was always zero.

   Measured rather than reasoned. A deliberately false assertion added to the
   unmodified file:

       FAIL  DELIBERATE FAILURE — this run must go red
       All assertions passed on WordPress 7.1 with WooCommerce 11.1.0.
       EXIT=0

   and with the fix, the same forced failure:

       FAIL  DELIBERATE FAILURE — this run must go red
       1 assertion(s) failed on WordPress 7.1.
       EXIT=1

   The FAIL lines always printed, so failures found by reading the output were
   real finds. What was broken is the CI gate: a regression on either forge
   would have gone green. Four lines at the top now prove the counter reaches
   the verdict before anything relies on it — this is the one file whose whole
   job is to be able to go red, so it checks that it can.

2. `git ls-files` QUOTES NON-ASCII PATHS. "TESTMILJØ.md" comes back as the
   literal string "TESTMILJ\303\230.md", which matches no pattern in
   .distignore, so the file is kept — an internal document walking into the
   release zip with nothing said. Fixed in ci/dist-files.sh and in
   ci/static.sh's trailing-?> check, which had the same bug and would have
   silently skipped the file it exists to check. Verified by adding such a
   filename and watching it be excluded. `done < <(...)` rather than a pipe, or
   the loop runs in a subshell and the failure counter stops incrementing.

3. THE FEED CACHE WAS SIX HOURS, and Kristian hit it: a release published and
   his shop shown nothing. The reasoning ("WordPress checks twice a day") was
   wrong. WordPress gates its own check by screen in wp_update_plugins() — one
   minute on Dashboard → Updates, one hour on Plugins, 2–12 hours in the
   background — so WordPress is already the rate limiter and a longer cache
   only hides releases underneath it. Fifteen minutes now, pinned by an
   assertion.

4. The wp_update_plugins() assertion made a real HTTP request, because the
   force-check tests had just deleted the transient. It passed only because
   this feed repo is public, which made it measure the environment rather than
   the code. Re-primed first.

ci_in_container() replaces the inline docker plumbing in ci_php(), taken from
the subscriptions port: the script goes in as a file rather than interpolated
into `sh -c`, and --entrypoint sh matters for images whose entrypoint swallows
its arguments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:18:22 +02:00

465 lines
19 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;
( 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 ) {
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 ) );
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
);
// 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 ) );
wc_edm_is( 'the current version is answered too, not passed over', WC_EDM_VERSION, is_array( $offered ) ? $offered['version'] : null );
/*
* 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 );
}
printf(
"All assertions passed on WordPress %s with WooCommerce %s.\n",
get_bloginfo( 'version' ),
defined( 'WC_VERSION' ) ? WC_VERSION : '?'
);