Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc0aaf9272 | ||
|
|
f6ea46a93e | ||
|
|
2b289c6763 | ||
|
|
9931c74b45 | ||
|
|
8143d146e4 | ||
|
|
2490f733de | ||
|
|
6ef4ee8fe5 | ||
|
|
d9de751e3f | ||
|
|
cb58ffe26e |
+17
-5
@@ -1,10 +1,10 @@
|
||||
=== WooCommerce Email Discount Manager ===
|
||||
=== Nolimit Discounts ===
|
||||
Contributors: kristianwind
|
||||
Tags: woocommerce, discount, email, shipping, coupon
|
||||
Requires at least: 5.8
|
||||
Tested up to: 7.1
|
||||
Tested up to: 7.1.2
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 1.5.3
|
||||
Stable tag: 1.6.2
|
||||
WC requires at least: 6.0
|
||||
WC tested up to: 11.1.0
|
||||
License: GPLv2 or later
|
||||
@@ -14,7 +14,7 @@ Apply discounts and show/hide shipping methods based on the customer's email add
|
||||
|
||||
== Description ==
|
||||
|
||||
WooCommerce Email Discount Manager lets you give discounts and filter available
|
||||
Nolimit Discounts lets you give discounts and filter available
|
||||
shipping methods based on the email address a customer logs in or checks out with.
|
||||
|
||||
= Discount Groups =
|
||||
@@ -63,7 +63,7 @@ coupons in a dropdown.
|
||||
1. Upload the `woocommerce-email-discount-manager` folder to `/wp-content/plugins/`.
|
||||
2. Make sure WooCommerce is installed and active.
|
||||
3. Activate the plugin through the Plugins screen in WordPress.
|
||||
4. Go to WooCommerce → Email Discounts to configure groups, assignments and shipping rules.
|
||||
4. Go to Nolimit → Discounts to configure groups, assignments and shipping rules.
|
||||
|
||||
= REST API =
|
||||
|
||||
@@ -96,6 +96,18 @@ The most specific match wins: exact email, then domain, then partial match.
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 1.6.2 =
|
||||
* Declares compatibility with WordPress 7.1.2 and WooCommerce 11.1.2, both tested. No functional change.
|
||||
|
||||
|
||||
= 1.6.1 =
|
||||
* The status label on the Nolimit dashboard card is now readable. It was white text on a light overlay, which on this plugin's green gave 3.5:1 where 4.5:1 is the accessibility minimum.
|
||||
|
||||
|
||||
= 1.6.0 =
|
||||
* A shipping method a group is shown is now reserved for that group: customers who match no group no longer see it. Previously a method set up for approved addresses stayed on the checkout for everyone, so free delivery meant for one company could be chosen by anybody. Customers are never left with no shipping method at all.
|
||||
|
||||
|
||||
= 1.4.6 =
|
||||
* Housekeeping: the plugin's source moved to a shorter address. Updates continue to arrive as before, and nothing needs doing.
|
||||
|
||||
|
||||
@@ -60,10 +60,15 @@ done
|
||||
git diff --quiet && { echo "Nothing actually changed on disk — check the sed patterns." >&2; exit 1; }
|
||||
|
||||
branch="compat/tested-up-to-$current_wp-wc-$current_wc"
|
||||
git config user.name "compatibility check"
|
||||
git config user.email "noreply@localhost"
|
||||
git checkout -b "$branch"
|
||||
git commit -am "Tested against WordPress $current_wp and WooCommerce $current_wc
|
||||
# The identity is scoped to THIS commit with `git -c`, never written to the
|
||||
# clone. `git config user.name` persists in .git/config, so every later commit
|
||||
# in this working copy — a release days afterwards, by a person — would be
|
||||
# authored by the robot with no Co-Authored-By trailer. git announces a
|
||||
# MISSING identity and says nothing about a wrong one, so that is silent.
|
||||
# Signing this one commit as the robot is right: it is not a person's.
|
||||
git -c user.name="compatibility check" -c user.email="noreply@localhost" \
|
||||
commit -am "Tested against WordPress $current_wp and WooCommerce $current_wc
|
||||
|
||||
$(printf '%s\n' "${changed[@]}")
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ ci_load_conf() {
|
||||
# leaves the floors empty, every comparison becomes a no-op, and the hollow
|
||||
# checks these were added to fix come back silently — the same shape of bug.
|
||||
local missing=
|
||||
for var in PLUGIN_SLUG PLUGIN_MAIN TEXT_DOMAIN NEEDS_WOOCOMMERCE MIN_SHIPPED_PHP_FILES MIN_TRANSLATION_CALLS RELEASE_REPO WPMGR_MENU_SHA; do
|
||||
for var in PLUGIN_SLUG PLUGIN_MAIN TEXT_DOMAIN NEEDS_WOOCOMMERCE MIN_SHIPPED_PHP_FILES MIN_TRANSLATION_CALLS RELEASE_REPO WPMGR_MENU_SHA OLD_PLUGIN_NAME MIN_SITE_ASSERTIONS; do
|
||||
[ -n "${!var:-}" ] || missing="$missing $var"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
|
||||
+19
-2
@@ -75,7 +75,13 @@ RELEASE_REPO=kw/nolimit-discounts-dist
|
||||
# The frozen hash is therefore a family-wide fact, not a local one, and taking
|
||||
# a new one is something all six do in the same window.
|
||||
#
|
||||
# 471b0a2a = contract 1.2.2: the icon box is sized 100%/100% instead of a
|
||||
# 75b04eb4 = contract 1.2.3: the dashboard status pill is rgba(0,0,0,.18)
|
||||
# instead of 22% white. The pill is white 11px text composited over the card
|
||||
# colour, and 22% white is a floor: a card has to reach about 7.7:1 before the
|
||||
# pill clears 4.5:1. This plugin's #1a7a52 gave 3.50 and now gives 7.15. It
|
||||
# could not be fixed by choosing a different card colour — the whole family
|
||||
# band fails, including the no-colour fallback.
|
||||
# Earlier: 471b0a2a = 1.2.2: the icon box is sized 100%/100% instead of a
|
||||
# hardcoded 36x34. WordPress declares that parent three times and they
|
||||
# disagree (36x34 open, 35x30 folded, 34x30 under 960px), so a fixed number is
|
||||
# right in one state and wrong in the two a laptop is usually in.
|
||||
@@ -91,4 +97,15 @@ RELEASE_REPO=kw/nolimit-discounts-dist
|
||||
# backslashes, hex digits and spaces, so there is nothing left to leave the
|
||||
# string with. Verified here against seven hostile inputs.
|
||||
# Earlier frozen hashes: 184cebdc = 1.1.0, f4862d5c = 1.0.0.
|
||||
WPMGR_MENU_SHA=471b0a2aab47b6b7f8d839ac8b12d60637bbe2c07df86f7e40089c677fbab3cc
|
||||
WPMGR_MENU_SHA=75b04eb4fe9eda0119312075accf2fc77de244e5c6b759d624a8b1d254c0fc99
|
||||
|
||||
# The name this plugin used before the Nolimit rename. Kept so ci/static.sh
|
||||
# can fail when it reappears in text a user reads — a sentence that goes stale
|
||||
# breaks nothing by itself, which is why it survives.
|
||||
OLD_PLUGIN_NAME="WooCommerce Email Discount Manager"
|
||||
|
||||
# The fewest assertions a complete run of tests/smoke.php prints. Verified by
|
||||
# ci/site-test.sh rather than only inside the file: a smoke file runs in
|
||||
# function scope, so a `return` partway through skips its own floor and the
|
||||
# run reports green having tested two thirds of what it claims.
|
||||
MIN_SITE_ASSERTIONS=60
|
||||
|
||||
@@ -205,6 +205,54 @@ ann=$(ci_php ci/check-textdomain.php "$TEXT_DOMAIN" selftest-dotted 2>&1 \
|
||||
&& ok "the annotated path exists on disk: $ann" \
|
||||
|| bad "the annotated path is not a file: '$ann'"
|
||||
|
||||
# ------------------------------------------------------- the clone's git identity
|
||||
# 🔴 Grade 2 evidence: the exact line ci/bump-tested-up-to.sh carried until
|
||||
# 2026-09-25, not an arbitrary `git config`. That line writes .git/config, so
|
||||
# every later commit in the clone — a release, by a person — is authored by the
|
||||
# robot with no trailer. Six such commits in one sibling plugin, two in
|
||||
# another. git warns about an identity it cannot FIND and never about a wrong
|
||||
# one, so nothing in the repository ever says so.
|
||||
echo
|
||||
echo "→ ci/static.sh must catch a ci/ script that configures the clone"
|
||||
cd "$work/repo"
|
||||
cp ci/build-zip.sh "$work/build-zip.orig"
|
||||
for scope in '' '--local' '--global'; do
|
||||
cp "$work/build-zip.orig" ci/build-zip.sh
|
||||
line="git config ${scope:+$scope }user.name \"compatibility check\""
|
||||
printf '%s\n' "$line" >> ci/build-zip.sh
|
||||
# The plant must BE there before any verdict is believed. A `printf` that
|
||||
# silently went somewhere else leaves the control state, and every assertion
|
||||
# below then passes while proving nothing — a hollow probe wearing the
|
||||
# costume of the most rigorous thing in the suite.
|
||||
if [ "$(grep -cF "$line" ci/build-zip.sh)" -lt 1 ]; then
|
||||
bad "could not stage the git-identity fixture for '${scope:-no flag}'"
|
||||
continue
|
||||
fi
|
||||
id_out=$(./ci/static.sh 2>&1 || true)
|
||||
if printf '%s' "$id_out" | grep -q 'sets a git identity that outlives it'; then
|
||||
ok "flagged git config ${scope:-(no flag)} user.name"
|
||||
else
|
||||
bad "NOT FLAGGED: git config ${scope:-(no flag)} user.name — the clone keeps the robot's name"
|
||||
fi
|
||||
done
|
||||
|
||||
# The correct form must NOT fire, or the fix trips the check that exists to
|
||||
# protect it and the next person deletes one of the two.
|
||||
cp "$work/build-zip.orig" ci/build-zip.sh
|
||||
printf '%s\n' 'git -c user.name="compatibility check" -c user.email=x commit -m y' >> ci/build-zip.sh
|
||||
scoped_out=$(./ci/static.sh 2>&1 || true)
|
||||
printf '%s' "$scoped_out" | grep -q 'sets a git identity that outlives it' \
|
||||
&& bad "FALSE POSITIVE on 'git -c user.name=… commit' — that is the fix, not the fault" \
|
||||
|| ok "ignored the scoped 'git -c … commit' form"
|
||||
cp "$work/build-zip.orig" ci/build-zip.sh
|
||||
|
||||
# And the passing line must carry a number: "every script is clean" reads the
|
||||
# same over zero files as over eleven.
|
||||
clean_out=$(./ci/static.sh 2>&1 || true)
|
||||
printf '%s' "$clean_out" | grep -qE "^ [0-9]+ scripts in ci/ leave the clone's git identity alone" \
|
||||
&& ok "the git-identity check reports how many scripts it read" \
|
||||
|| bad "the git-identity check printed no count — it cannot tell 11 scripts from 0"
|
||||
|
||||
# ------------------------------------------------------------------- plugin.conf
|
||||
echo
|
||||
echo "→ the floors must be required, not defaulted"
|
||||
|
||||
+31
-1
@@ -177,9 +177,39 @@ wp plugin list --name="$PLUGIN_SLUG" --field=status | grep -qx active \
|
||||
|| { echo "The plugin did not stay active after activation." >&2; exit 1; }
|
||||
echo " activated $(wp plugin get "$PLUGIN_SLUG" --field=version)"
|
||||
|
||||
# 🔴 The output is captured and the summary checked HERE, by the caller.
|
||||
#
|
||||
# A smoke file cannot assert about its own early exit: `wp eval-file` runs it
|
||||
# in function scope, so a `return` partway through skips everything after it —
|
||||
# including any floor the file carries at the bottom. Measured: truncating
|
||||
# tests/smoke.php before the shipping section ran two thirds of the assertions
|
||||
# and this script reported green, because the file's own floor was one of the
|
||||
# things never reached.
|
||||
#
|
||||
# So the number the file prints is verified against MIN_SITE_ASSERTIONS out
|
||||
# here, where an early return cannot reach. The floor inside the file stays as
|
||||
# well; it catches a short run that still gets to the end.
|
||||
for f in ${SITE_TESTS:-}; do
|
||||
ci_step "$f"
|
||||
wp eval-file "wp-content/plugins/$PLUGIN_SLUG/$f"
|
||||
out=$(wp eval-file "wp-content/plugins/$PLUGIN_SLUG/$f" 2>&1) || { printf '%s\n' "$out"; exit 1; }
|
||||
printf '%s\n' "$out"
|
||||
|
||||
case "$f" in
|
||||
*smoke*)
|
||||
ran=$(printf '%s' "$out" | sed -n -E 's/^All ([0-9]+) assertions passed.*/\1/p' | tail -1)
|
||||
if [ -z "$ran" ]; then
|
||||
echo
|
||||
ci_fail "$f printed no assertion total. It exited before its summary — which is what a file that returned halfway through looks like, and it cannot report that about itself."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$ran" -lt "$MIN_SITE_ASSERTIONS" ]; then
|
||||
echo
|
||||
ci_fail "$f ran only $ran assertions, expected at least $MIN_SITE_ASSERTIONS. Part of the suite did not run."
|
||||
exit 1
|
||||
fi
|
||||
echo " $ran assertions, at or above the floor of $MIN_SITE_ASSERTIONS"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ci_step "PHP notices and deprecations"
|
||||
|
||||
+62
-2
@@ -141,6 +141,37 @@ else
|
||||
echo " $checked plugin-relative include(s) checked"
|
||||
fi
|
||||
|
||||
readme=
|
||||
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
|
||||
|
||||
ci_step "the plugin is called the same thing everywhere"
|
||||
# 🔴 The rename moved the code and left the text behind: the readme still told
|
||||
# people to look under WooCommerce for a page that had moved to the Nolimit
|
||||
# menu, and three notices the PLUGIN SHOWS THE USER still used the old name.
|
||||
# Kristian found it by reading the readme, which is the only reason it was
|
||||
# found at all — nothing fails when a sentence goes stale.
|
||||
#
|
||||
# Same failure as a privacy page standing still while the code moves. The name
|
||||
# in the header is the one WordPress shows, so it is the one everything else
|
||||
# has to agree with.
|
||||
header_name=$(ci_plugin_header 'Plugin Name')
|
||||
echo " header: $header_name"
|
||||
readme_name=$(sed -n -E 's/^=== (.+) ===$/\1/p' "$readme" 2>/dev/null | head -1)
|
||||
echo " $readme: $readme_name"
|
||||
[ "$header_name" = "$readme_name" ] \
|
||||
|| note_fail "The plugin header says '$header_name' but $readme says '$readme_name'."
|
||||
|
||||
# Old names in text a user reads. The changelog is exempt: an entry describes
|
||||
# what was true for that release, and rewriting it would be a different lie.
|
||||
stale=$(./ci/dist-files.sh | grep -E '\.php$' \
|
||||
| xargs grep -nF "$OLD_PLUGIN_NAME" 2>/dev/null || true)
|
||||
if [ -n "$stale" ]; then
|
||||
printf '%s\n' "$stale" | sed 's/^/ /'
|
||||
note_fail "The old plugin name '$OLD_PLUGIN_NAME' is still in shipped code."
|
||||
else
|
||||
echo " no shipped file still says '$OLD_PLUGIN_NAME'"
|
||||
fi
|
||||
|
||||
ci_step "one version, stated once"
|
||||
# WordPress compares the update feed against the header and nothing else, so a
|
||||
# constant or a readme that disagrees with it is how a release ends up either
|
||||
@@ -276,6 +307,36 @@ elif [ "$fails" = "$before" ]; then
|
||||
echo " $secrets_scanned tracked files read, nothing that looks like a key or a private key"
|
||||
fi
|
||||
|
||||
ci_step "no ci/ script configures the clone it was handed"
|
||||
# `git config user.name` writes .git/config and OUTLIVES the script. A helper
|
||||
# that sets an identity before committing therefore renames every later commit
|
||||
# in that working copy, including a release made by a person days afterwards
|
||||
# and with no Co-Authored-By trailer. git announces an identity it cannot FIND
|
||||
# and says nothing about one that is wrong, so the missing case is loud and
|
||||
# this one is silent: nothing fails, nothing warns, and the author is only
|
||||
# visible to somebody reading `git log` for another reason.
|
||||
#
|
||||
# ci/bump-tested-up-to.sh did this until it was scoped to `git -c … commit`,
|
||||
# which still signs its own commit as the robot — correct, that one is not a
|
||||
# person's — without touching the clone. Measured across the family: six
|
||||
# misattributed hand commits in one sibling, two in another, including
|
||||
# releases. This tree was clean and one run away.
|
||||
#
|
||||
# Any flag, not just --local: --global sets the whole machine's identity, which
|
||||
# is the same fault one scope wider and reaches every repository on the box.
|
||||
#
|
||||
# The rule: a script may configure ITSELF, never the working copy it was
|
||||
# handed. Reported by the Nolimit Security session 2026-09-25.
|
||||
scripts_scanned=$(git ls-files 'ci/*.sh' | wc -l)
|
||||
if [ "$scripts_scanned" -lt 1 ]; then
|
||||
note_fail "The git-identity scan found no ci/*.sh files at all, so it proved nothing."
|
||||
elif git ls-files -z 'ci/*.sh' \
|
||||
| xargs -0 grep -nE '^[^#]*git config([[:space:]]+--[a-z-]+)*[[:space:]]+user\.'; then
|
||||
note_fail "A ci/ script sets a git identity that outlives it. Scope it to the one commit with 'git -c user.name=… commit', which does not write .git/config."
|
||||
else
|
||||
echo " $scripts_scanned scripts in ci/ leave the clone's git identity alone"
|
||||
fi
|
||||
|
||||
ci_step "no placeholders left in the plugin header"
|
||||
# The header is the plugin's business card: wp-admin shows the author and the
|
||||
# links to whoever installs it, so a placeholder there survives for as long as
|
||||
@@ -323,8 +384,7 @@ behind() { # claimed current -> true when claimed is the older of the two
|
||||
# plugin header, so checking the header for it reports "missing" on a plugin
|
||||
# that states it correctly. "WC tested up to" is the opposite: WooCommerce
|
||||
# reads that one from the plugin header and nowhere else.
|
||||
readme=
|
||||
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
|
||||
|
||||
|
||||
tested_wp=
|
||||
[ -n "$readme" ] && tested_wp=$(sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
|
||||
|
||||
@@ -507,6 +507,25 @@ class WC_EDM_DB {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every shipping method any group reserves for itself.
|
||||
*
|
||||
* A "show" rule means the group sees that method. It also has to mean
|
||||
* nobody else does — otherwise reserving a free-delivery method for an
|
||||
* approved group leaves it on the checkout for every other customer, which
|
||||
* is what a shop notices as lost shipping revenue rather than as a bug.
|
||||
*
|
||||
* @return string[] Rate ids, e.g. "free_shipping:15".
|
||||
*/
|
||||
public static function restricted_shipping_methods() {
|
||||
global $wpdb;
|
||||
$table = self::table( 'shipping_rules' );
|
||||
|
||||
$ids = $wpdb->get_col( "SELECT DISTINCT shipping_method_id FROM {$table} WHERE action = 'show'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
return is_array( $ids ) ? $ids : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all shipping rules for a group.
|
||||
*
|
||||
|
||||
@@ -330,15 +330,25 @@ class WC_EDM_Frontend {
|
||||
public function filter_shipping_rates( $rates, $package ) {
|
||||
$match = $this->get_current_match();
|
||||
|
||||
if ( ! $match ) {
|
||||
return $rates;
|
||||
}
|
||||
|
||||
if ( 'group' === $match->assignment_type && $match->group_id ) {
|
||||
// $match is null for a customer who matches nothing — the common case,
|
||||
// and the one this whole method exists to constrain now.
|
||||
if ( $match && 'group' === $match->assignment_type && $match->group_id ) {
|
||||
$rates = $this->apply_group_shipping_rules( $rates, (int) $match->group_id );
|
||||
} else {
|
||||
// 🔴 Everyone else — including a matched customer whose assignment
|
||||
// is not a group — must not see a method some group reserved.
|
||||
//
|
||||
// This used to return $rates untouched, and a test asserted that
|
||||
// as intended: "an unmatched customer sees every method". It made
|
||||
// a "show" rule a whitelist FOR THE GROUP and nothing more, so
|
||||
// reserving free delivery for approved addresses left it on the
|
||||
// checkout for every other customer. Reported from a live shop,
|
||||
// where a gmail address could pick a free-delivery method meant
|
||||
// for one company.
|
||||
$rates = $this->remove_restricted_rates( $rates );
|
||||
}
|
||||
|
||||
if ( ! empty( $match->free_shipping ) ) {
|
||||
if ( $match && ! empty( $match->free_shipping ) ) {
|
||||
foreach ( $rates as $rate ) {
|
||||
$rate->set_cost( 0 );
|
||||
$rate->set_taxes( array() );
|
||||
@@ -348,6 +358,33 @@ class WC_EDM_Frontend {
|
||||
return $rates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every method some group reserved for itself.
|
||||
*
|
||||
* @param array $rates Available shipping rates.
|
||||
* @return array
|
||||
*/
|
||||
private function remove_restricted_rates( $rates ) {
|
||||
$restricted = WC_EDM_DB::restricted_shipping_methods();
|
||||
|
||||
if ( empty( $restricted ) ) {
|
||||
return $rates;
|
||||
}
|
||||
|
||||
$filtered = array();
|
||||
|
||||
foreach ( $rates as $rate_id => $rate ) {
|
||||
if ( ! in_array( $rate_id, $restricted, true ) ) {
|
||||
$filtered[ $rate_id ] = $rate;
|
||||
}
|
||||
}
|
||||
|
||||
// The same guard the group rules carry: a shop that reserved every
|
||||
// method it offers would otherwise leave an ordinary customer unable
|
||||
// to check out at all, which is worse than the leak being fixed.
|
||||
return empty( $filtered ) ? $rates : $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a group's show/hide shipping rules.
|
||||
*
|
||||
@@ -362,8 +399,14 @@ class WC_EDM_Frontend {
|
||||
private function apply_group_shipping_rules( $rates, $group_id ) {
|
||||
$rules = WC_EDM_DB::get_shipping_rules( $group_id );
|
||||
|
||||
// 🔴 Not `return $rates` — a group with no rules of its own must still
|
||||
// not see what ANOTHER group reserved. Same for a group carrying only
|
||||
// "hide" rules: its own shown-set is empty, so the whitelist below
|
||||
// never engages and every other group's reserved method would survive.
|
||||
// The leak this fixes was reported for customers matching nothing; it
|
||||
// existed for these two shapes as well.
|
||||
if ( empty( $rules ) ) {
|
||||
return $rates;
|
||||
return $this->remove_restricted_rates( $rates );
|
||||
}
|
||||
|
||||
$show = array();
|
||||
@@ -379,6 +422,9 @@ class WC_EDM_Frontend {
|
||||
|
||||
$filtered = array();
|
||||
|
||||
// Reserved by some group, but not by THIS one.
|
||||
$others = array_diff( WC_EDM_DB::restricted_shipping_methods(), $show );
|
||||
|
||||
foreach ( $rates as $rate_id => $rate ) {
|
||||
// Whitelist mode: only explicitly shown methods survive.
|
||||
if ( ! empty( $show ) && ! in_array( $rate_id, $show, true ) ) {
|
||||
@@ -389,6 +435,10 @@ class WC_EDM_Frontend {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( in_array( $rate_id, $others, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[ $rate_id ] = $rate;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
}
|
||||
|
||||
if ( ! defined( 'WPMGR_MENU_VERSION' ) ) {
|
||||
define( 'WPMGR_MENU_VERSION', '1.2.2' );
|
||||
define( 'WPMGR_MENU_VERSION', '1.2.3' );
|
||||
}
|
||||
|
||||
if ( ! defined( 'WPMGR_MENU_SLUG' ) ) {
|
||||
@@ -337,8 +337,19 @@ if ( ! function_exists( 'wpmgr_ensure_menu' ) ) {
|
||||
. '.wpmgr-headline{font-size:22px;font-weight:600;margin:0 0 10px}'
|
||||
. '.wpmgr-card dl{display:grid;grid-template-columns:1fr auto;gap:4px 12px;margin:0;font-size:13px}'
|
||||
. '.wpmgr-card dt{color:#646970;margin:0}.wpmgr-card dd{margin:0;text-align:right}'
|
||||
// 🔴 A DARK overlay, not a light one. White 11px text on
|
||||
// rgba(255,255,255,.22) over a card colour is 3.45:1 on this
|
||||
// plugin's green and 3.42:1 on a blue — under the 4.5:1 that small
|
||||
// text needs, on every family colour dark enough to need white
|
||||
// text in the first place. rgba(0,0,0,.18) takes the same four
|
||||
// colours to 6.94–9.37:1, because darkening a background that
|
||||
// already carries white text can only help.
|
||||
//
|
||||
// Measured rather than reasoned: the composite was read back out
|
||||
// of a canvas in headless Chromium, not computed — #50977f for the
|
||||
// old rule, #19644b for this one.
|
||||
. '.wpmgr-state{font-size:11px;text-transform:uppercase;letter-spacing:.04em;'
|
||||
. 'background:rgba(255,255,255,.22);padding:2px 8px;border-radius:10px}'
|
||||
. 'background:rgba(0,0,0,.18);padding:2px 8px;border-radius:10px}'
|
||||
. '.wpmgr-card footer{padding:12px 18px;border-top:1px solid #f0f0f1}'
|
||||
. '</style>';
|
||||
|
||||
|
||||
+117
-2
@@ -31,6 +31,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
* that it can.
|
||||
*/
|
||||
$GLOBALS['wc_edm_failures'] = 0;
|
||||
$GLOBALS['wc_edm_passes'] = 0;
|
||||
( static function () { ++$GLOBALS['wc_edm_failures']; } )();
|
||||
if ( 1 !== $GLOBALS['wc_edm_failures'] ) {
|
||||
echo "The failure counter does not reach the verdict: this file cannot go red.\n";
|
||||
@@ -47,6 +48,7 @@ $GLOBALS['wc_edm_failures'] = 0;
|
||||
*/
|
||||
function wc_edm_ok( $what, $condition, $detail = '' ) {
|
||||
if ( $condition ) {
|
||||
++$GLOBALS['wc_edm_passes'];
|
||||
printf( " ok %s\n", $what );
|
||||
return;
|
||||
}
|
||||
@@ -194,9 +196,78 @@ 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 sees every method', 2, count( $filtered ) );
|
||||
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() );
|
||||
|
||||
@@ -349,6 +420,36 @@ if ( function_exists( 'wpmgr_menu_glyph_style' ) ) {
|
||||
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
|
||||
@@ -700,8 +801,22 @@ 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 assertions passed on WordPress %s with WooCommerce %s.\n",
|
||||
"All %d assertions passed on WordPress %s with WooCommerce %s.\n",
|
||||
$GLOBALS['wc_edm_passes'],
|
||||
get_bloginfo( 'version' ),
|
||||
defined( 'WC_VERSION' ) ? WC_VERSION : '?'
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Plugin URI: https://gitea.nolimit.dk/kw/woo-discount-manager
|
||||
* Update URI: https://gitea.nolimit.dk/kw/nolimit-discounts-dist
|
||||
* Description: Apply discounts and show/hide shipping methods based on the customer's email address (full address, domain or partial match).
|
||||
* Version: 1.5.3
|
||||
* Version: 1.6.2
|
||||
* Author: Kristian Wind
|
||||
* Author URI: https://gitea.nolimit.dk/kw
|
||||
* License: GPL-2.0-or-later
|
||||
@@ -12,7 +12,7 @@
|
||||
* Requires at least: 5.8
|
||||
* Requires PHP: 7.4
|
||||
* WC requires at least: 6.0
|
||||
* WC tested up to: 11.1.0
|
||||
* WC tested up to: 11.1.2
|
||||
* Requires Plugins: woocommerce
|
||||
*/
|
||||
|
||||
@@ -28,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define( 'WC_EDM_VERSION', '1.5.3' );
|
||||
define( 'WC_EDM_VERSION', '1.6.2' );
|
||||
define( 'WC_EDM_PLUGIN_FILE', __FILE__ );
|
||||
define( 'WC_EDM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'WC_EDM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
@@ -211,7 +211,7 @@ final class WC_Email_Discount_Manager {
|
||||
}
|
||||
|
||||
echo '<div class="notice notice-error"><p>';
|
||||
echo esc_html__( 'WooCommerce Email Discount Manager could not load all of its files, so some features are unavailable. If you just updated the plugin, reload this page — the upload may still have been in progress. If the warning persists, upload the plugin again.', 'wc-email-discount' );
|
||||
echo esc_html__( 'Nolimit Discounts could not load all of its files, so some features are unavailable. If you just updated the plugin, reload this page — the upload may still have been in progress. If the warning persists, upload the plugin again.', 'wc-email-discount' );
|
||||
echo '</p><p><code>';
|
||||
echo implode( '</code>, <code>', array_map( 'esc_html', $this->missing_files ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '</code></p></div>';
|
||||
@@ -283,7 +283,7 @@ final class WC_Email_Discount_Manager {
|
||||
*/
|
||||
public function woocommerce_missing_notice() {
|
||||
echo '<div class="notice notice-error"><p>';
|
||||
esc_html_e( 'WooCommerce Email Discount Manager requires WooCommerce to be installed and active.', 'wc-email-discount' );
|
||||
esc_html_e( 'Nolimit Discounts requires WooCommerce to be installed and active.', 'wc-email-discount' );
|
||||
echo '</p></div>';
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ final class WC_Email_Discount_Manager {
|
||||
if ( ! class_exists( 'WooCommerce' ) && ! in_array( 'woocommerce/woocommerce.php', $active_plugins, true ) ) {
|
||||
deactivate_plugins( plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
wp_die(
|
||||
esc_html__( 'WooCommerce Email Discount Manager requires WooCommerce to be installed and active. Please activate WooCommerce first.', 'wc-email-discount' ),
|
||||
esc_html__( 'Nolimit Discounts requires WooCommerce to be installed and active. Please activate WooCommerce first.', 'wc-email-discount' ),
|
||||
esc_html__( 'Plugin activation error', 'wc-email-discount' ),
|
||||
array( 'back_link' => true )
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user