Test against the newest WordPress and WooCommerce, and ship updates
Three things this repository could not do: prove the plugin still works on a
WordPress or WooCommerce released after it, get a fix onto a shop without
someone uploading a zip, and do either of those for the next plugin without
starting over.
ci/ is the test harness. ci/site-test.sh builds a throwaway WordPress +
WooCommerce in containers at any versions you name — including `nightly` and
`beta` — installs exactly the files the release zip will contain, and runs
tests/smoke.php inside it with wp eval-file. Real cart, real coupons, real
WC_Shipping_Rate objects: a mocked cart keeps passing after WooCommerce has
changed underneath it, which is the one failure worth catching here. WP_DEBUG
is on and any notice logged from this plugin's directory fails the build,
because that is the shape an upgrade actually breaks in — nothing fails, a
notice appears, and two releases later it is a fatal.
compat.yml runs that weekly against today's releases, the next WordPress and
the next WooCommerce, the oldest PHP the header promises and the newest PHP.
When it is green it opens a PR bumping "Tested up to" and "WC tested up to" —
only when green, because WooCommerce shows that second value to the shop owner
as an untested-plugin banner, and that banner is what they read before deciding
whether to press update.
includes/class-updater.php is the distribution half. It hooks
update_plugins_{hostname} for the host in the new "Update URI" header and reads
the Gitea release API, so WordPress handles the notice, the download, the
"Enable auto-updates" link and wp plugin update with no further code. Update
URI earns its place twice: since 5.8 a plugin that declares one is never asked
about on wordpress.org, so a .org plugin sharing this slug cannot be pushed
onto a shop running this one. The check sends nothing about the site and
overrides WordPress's default user agent, which carries the site's own URL.
release.yml tags, tests, builds, publishes — then reads the feed back with no
credentials. A private repository and a release with no zip attached both leave
a page that looks perfect to anyone logged in and an update no shop is ever
offered.
Measured before committing, all green: WP 7.1 + WC 11.1 on PHP 8.3 and 7.4,
WP 7.2-alpha on 8.3, and the beta cell falling back to stable on 8.4. No
deprecations from this plugin on any of them.
Three guards are there because the bug they catch is invisible without them:
the annotated tag is re-fetched before its message is read (checkout turns it
into a lightweight tag pointing at the merge commit), the two workflow roots
are kept in sync (.gitea/workflows hides .github/workflows entirely), and every
file the code loads must be in the release zip — which caught class-updater.php
being referenced and uncommitted while every local test passed.
docs/REUSE.md is the port to the next plugin: copy ci/, rewrite five lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e4a40e53a0
commit
eca2851d35
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
/**
|
||||
* Build the shop state the smoke test asserts against.
|
||||
*
|
||||
* Run inside the throwaway site with `wp eval-file`, so WordPress and
|
||||
* WooCommerce are fully loaded and this is real data in real tables — not a
|
||||
* mocked cart that would keep passing after WooCommerce changed underneath it.
|
||||
*
|
||||
* @package WC_Email_Discount_Manager
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a product category and return its term ID.
|
||||
*
|
||||
* @param string $name Category name.
|
||||
* @return int
|
||||
*/
|
||||
function wc_edm_ci_category( $name ) {
|
||||
$term = term_exists( $name, 'product_cat' );
|
||||
if ( ! $term ) {
|
||||
$term = wp_insert_term( $name, 'product_cat' );
|
||||
}
|
||||
return (int) $term['term_id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple product and return its ID.
|
||||
*
|
||||
* @param string $name Product name.
|
||||
* @param float $price Price.
|
||||
* @param int $category Product category term ID.
|
||||
* @return int
|
||||
*/
|
||||
function wc_edm_ci_product( $name, $price, $category ) {
|
||||
$product = new WC_Product_Simple();
|
||||
$product->set_name( $name );
|
||||
$product->set_regular_price( $price );
|
||||
$product->set_category_ids( array( $category ) );
|
||||
$product->set_catalog_visibility( 'visible' );
|
||||
$product->set_stock_status( 'instock' );
|
||||
return $product->save();
|
||||
}
|
||||
|
||||
$discounted = wc_edm_ci_category( 'CI Discounted' );
|
||||
$other = wc_edm_ci_category( 'CI Other' );
|
||||
|
||||
$product_in = wc_edm_ci_product( 'CI product in group', 100, $discounted );
|
||||
$product_out = wc_edm_ci_product( 'CI product outside group', 50, $other );
|
||||
|
||||
$group_id = WC_EDM_DB::insert_group(
|
||||
array(
|
||||
'name' => 'CI Group',
|
||||
'discount_percent' => 20,
|
||||
)
|
||||
);
|
||||
WC_EDM_DB::set_group_categories( $group_id, array( $discounted ) );
|
||||
|
||||
$coupon = new WC_Coupon();
|
||||
$coupon->set_code( 'ciflat10' );
|
||||
$coupon->set_discount_type( 'fixed_cart' );
|
||||
$coupon->set_amount( 10 );
|
||||
$coupon->save();
|
||||
|
||||
/**
|
||||
* Insert an email assignment, filling in the columns the caller did not name.
|
||||
*
|
||||
* @param array $data Partial assignment row.
|
||||
* @return int
|
||||
*/
|
||||
function wc_edm_ci_assign( $data ) {
|
||||
return WC_EDM_DB::insert_email(
|
||||
array_merge(
|
||||
array(
|
||||
'assignment_type' => 'group',
|
||||
'group_id' => null,
|
||||
'coupon_code' => null,
|
||||
'custom_discount' => null,
|
||||
'is_active' => 1,
|
||||
'free_shipping' => 0,
|
||||
),
|
||||
$data
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// The three pattern kinds, arranged so that priority is observable:
|
||||
// exact@company.com is matched by BOTH the exact rule and the domain rule, and
|
||||
// "partial" is contained in partialperson@elsewhere.test.
|
||||
wc_edm_ci_assign(
|
||||
array(
|
||||
'email_pattern' => '@company.com',
|
||||
'assignment_type' => 'group',
|
||||
'group_id' => $group_id,
|
||||
)
|
||||
);
|
||||
wc_edm_ci_assign(
|
||||
array(
|
||||
'email_pattern' => 'exact@company.com',
|
||||
'assignment_type' => 'custom',
|
||||
'custom_discount' => 50,
|
||||
)
|
||||
);
|
||||
wc_edm_ci_assign(
|
||||
array(
|
||||
'email_pattern' => 'partial',
|
||||
'assignment_type' => 'custom',
|
||||
'custom_discount' => 5,
|
||||
)
|
||||
);
|
||||
wc_edm_ci_assign(
|
||||
array(
|
||||
'email_pattern' => 'buyer@coupons.test',
|
||||
'assignment_type' => 'coupon',
|
||||
'coupon_code' => 'ciflat10',
|
||||
)
|
||||
);
|
||||
wc_edm_ci_assign(
|
||||
array(
|
||||
'email_pattern' => 'switched-off@company.dk',
|
||||
'assignment_type' => 'custom',
|
||||
'custom_discount' => 90,
|
||||
'is_active' => 0,
|
||||
)
|
||||
);
|
||||
|
||||
update_option(
|
||||
'wc_edm_ci_fixtures',
|
||||
array(
|
||||
'group_id' => $group_id,
|
||||
'category' => $discounted,
|
||||
'product_in' => $product_in,
|
||||
'product_out' => $product_out,
|
||||
)
|
||||
);
|
||||
|
||||
printf(
|
||||
" fixtures: group #%d (20%%) on category #%d, products #%d (100) and #%d (50), 5 assignments, coupon ciflat10\n",
|
||||
$group_id,
|
||||
$discounted,
|
||||
$product_in,
|
||||
$product_out
|
||||
);
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
<?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' )
|
||||
);
|
||||
|
||||
// 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 : '?'
|
||||
);
|
||||
Reference in New Issue
Block a user