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, '' ) && 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=, 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'], '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 : '?' );