Replace the single is_free_member flag with a real member-tier system
Design doc §07 extension: admin-configurable tiers, each with an arbitrary JSON benefit set, instead of one hardcoded boolean. Ships two benefit types on top of the new Membership\Tier lookup: the existing free-shipping threshold (refactored, not just left on the old flag) and a new "$10 off orders $100+, members only" cart discount — bundling purchases into one $100+ order saves BooksRun's flat per-order shipping fee outright, more than covering the discount. Researched official/well-supported WooCommerce plugins first (Memberships, Points and Rewards, Smart Coupons) — none combine tiers with configurable per-tier benefit sets, so building custom on the existing wp_bsc_member table was the right call. Registration is enabled via WooCommerce's own native My Account signup (deploy.sh) rather than a bespoke form, with a marketing-consent checkbox added via the standard extension hooks. Admin management: new `wp bookstore tier-*` CLI commands plus a WooCommerce Settings screen (this plugin's first admin UI). Migration drops and recreates bsc_member rather than backfilling — pre-launch, only synthetic data exists anywhere. Live-verified beyond the unit suite: real migration run, CLI commands end-to-end, a real registration submitted through the actual WC form (with and without consent), and the settings screen edited/created/ deleted tiers through real authenticated form POSTs with real nonces. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MqhgnCRjjVLFnZ3Jpn93Vg
This commit is contained in:
1 parent
7023044278
commit
13e7362579
13 files changed
+930
-43
No files matched your search
@@ -3,10 +3,20 @@
|
||||
namespace Bookstore\Core\Tests;
|
||||
|
||||
use Brain\Monkey;
|
||||
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
|
||||
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
|
||||
|
||||
abstract class TestCase extends PHPUnitTestCase
|
||||
{
|
||||
// Without this, Mockery's shouldReceive()/shouldNotReceive()
|
||||
// expectations are only ever verified at Mockery::close() — PHPUnit
|
||||
// never sees them as assertions, so a test that mocks a call and
|
||||
// never asserts anything else passes silently even if the mocked
|
||||
// call was never (or wrongly) made, just flagged "risky." This
|
||||
// trait's assertPostConditions() hook feeds Mockery's own verified-
|
||||
// expectation count into PHPUnit's assertion count instead.
|
||||
use MockeryPHPUnitIntegration;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Bookstore\Core\Tests\Unit\Membership;
|
||||
|
||||
use Bookstore\Core\Membership\MemberDiscount;
|
||||
use Bookstore\Core\Tests\TestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
|
||||
/**
|
||||
* "$10 off orders $100+, members only" (design doc §07 extension). The
|
||||
* real Membership\Tier class runs unmocked — only $wpdb (underneath
|
||||
* Tier's queries), get_current_user_id(), and a WC_Cart mock are
|
||||
* stubbed, so these tests exercise the actual threshold/amount lookup,
|
||||
* not just a canned discount value.
|
||||
*/
|
||||
class MemberDiscountTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
global $wpdb;
|
||||
$wpdb = null;
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_guest_gets_no_discount(): void
|
||||
{
|
||||
Functions\when('get_current_user_id')->justReturn(0);
|
||||
// Guest tier has no spend_discount benefit configured.
|
||||
$this->stub_wpdb_anonymous(['free_shipping_threshold' => ['threshold' => 25.00]]);
|
||||
|
||||
$cart = Mockery::mock(\WC_Cart::class);
|
||||
$cart->shouldReceive('get_subtotal')->andReturn('500.00');
|
||||
$cart->shouldNotReceive('add_fee');
|
||||
|
||||
MemberDiscount::apply($cart);
|
||||
}
|
||||
|
||||
public function test_member_below_min_spend_gets_no_discount(): void
|
||||
{
|
||||
Functions\when('get_current_user_id')->justReturn(7);
|
||||
$this->stub_wpdb_with_member_lookup(5, [
|
||||
'free_shipping_threshold' => ['threshold' => 15.00],
|
||||
'spend_discount' => ['min_spend' => 100.00, 'discount_amount' => 10.00],
|
||||
]);
|
||||
|
||||
$cart = Mockery::mock(\WC_Cart::class);
|
||||
$cart->shouldReceive('get_subtotal')->andReturn('80.00');
|
||||
$cart->shouldNotReceive('add_fee');
|
||||
|
||||
MemberDiscount::apply($cart);
|
||||
}
|
||||
|
||||
public function test_member_at_min_spend_gets_the_discount(): void
|
||||
{
|
||||
Functions\when('get_current_user_id')->justReturn(7);
|
||||
$this->stub_wpdb_with_member_lookup(5, [
|
||||
'free_shipping_threshold' => ['threshold' => 15.00],
|
||||
'spend_discount' => ['min_spend' => 100.00, 'discount_amount' => 10.00],
|
||||
]);
|
||||
|
||||
$cart = Mockery::mock(\WC_Cart::class);
|
||||
$cart->shouldReceive('get_subtotal')->andReturn('100.00');
|
||||
$cart->shouldReceive('add_fee')->once()->with('Member discount', -10.00, false);
|
||||
|
||||
MemberDiscount::apply($cart);
|
||||
}
|
||||
|
||||
public function test_member_above_min_spend_gets_the_discount(): void
|
||||
{
|
||||
Functions\when('get_current_user_id')->justReturn(7);
|
||||
$this->stub_wpdb_with_member_lookup(5, [
|
||||
'spend_discount' => ['min_spend' => 100.00, 'discount_amount' => 10.00],
|
||||
]);
|
||||
|
||||
$cart = Mockery::mock(\WC_Cart::class);
|
||||
$cart->shouldReceive('get_subtotal')->andReturn('142.50');
|
||||
$cart->shouldReceive('add_fee')->once()->with('Member discount', -10.00, false);
|
||||
|
||||
MemberDiscount::apply($cart);
|
||||
}
|
||||
|
||||
private function stub_wpdb_anonymous(array $benefits): void
|
||||
{
|
||||
global $wpdb;
|
||||
$wpdb = Mockery::mock('wpdb');
|
||||
$wpdb->prefix = 'wp_';
|
||||
$wpdb->shouldReceive('prepare')->once()->andReturn('SQL');
|
||||
$wpdb->shouldReceive('get_var')->never();
|
||||
$wpdb->shouldReceive('get_row')->once()->with('SQL', ARRAY_A)->andReturn([
|
||||
'tier_id' => 1,
|
||||
'slug' => 'guest',
|
||||
'name' => 'Guest',
|
||||
'sort_order' => 0,
|
||||
'benefits' => json_encode($benefits),
|
||||
]);
|
||||
}
|
||||
|
||||
private function stub_wpdb_with_member_lookup(int $tier_id, array $benefits): void
|
||||
{
|
||||
global $wpdb;
|
||||
$wpdb = Mockery::mock('wpdb');
|
||||
$wpdb->prefix = 'wp_';
|
||||
$wpdb->shouldReceive('prepare')->twice()->andReturn('SQL');
|
||||
$wpdb->shouldReceive('get_var')->once()->andReturn($tier_id);
|
||||
$wpdb->shouldReceive('get_row')->once()->with('SQL', ARRAY_A)->andReturn([
|
||||
'tier_id' => $tier_id,
|
||||
'slug' => 'free_member',
|
||||
'name' => 'Free Member',
|
||||
'sort_order' => 10,
|
||||
'benefits' => json_encode($benefits),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,12 @@ use Mockery;
|
||||
|
||||
/**
|
||||
* Launch gate: "$25/$15 shipping thresholds have automated tests" (design
|
||||
* doc §5/§8). threshold_for_current_customer() reads membership via a raw
|
||||
* $wpdb query and thresholds via get_option() (with the $25/$15 launch
|
||||
* numbers as the fallback defaults) — both are stubbed here rather than
|
||||
* hitting WordPress/a real database. The class's own docblock says
|
||||
* thresholds are option-backed "so the launch gate's automated test can
|
||||
* assert against the same source this reads from" — the last test below
|
||||
* proves exactly that.
|
||||
* doc §5/§8), now sourced from the current customer's tier
|
||||
* (Membership\Tier) rather than a hardcoded get_option() default. The
|
||||
* real Tier class runs unmocked here — only $wpdb (the layer underneath
|
||||
* Tier's own queries) and get_current_user_id() are stubbed, so these
|
||||
* tests exercise the actual guest-fallback/tier-resolution logic, not
|
||||
* just BSC_Free_Shipping's arithmetic.
|
||||
*/
|
||||
class BSCFreeShippingTest extends TestCase
|
||||
{
|
||||
@@ -29,16 +28,19 @@ class BSCFreeShippingTest extends TestCase
|
||||
public function test_guest_gets_default_25_dollar_threshold(): void
|
||||
{
|
||||
Functions\when('get_current_user_id')->justReturn(0);
|
||||
Functions\when('get_option')->alias(fn($name, $default) => $default);
|
||||
// user_id 0 short-circuits Tier::tier_for_user()'s ternary --
|
||||
// $wpdb->get_var() is never called, only find_by_slug('guest').
|
||||
$this->stub_wpdb_anonymous($this->tier_row('guest', ['free_shipping_threshold' => ['threshold' => 25.00]]));
|
||||
|
||||
$this->assertSame(25.00, BSC_Free_Shipping::threshold_for_current_customer());
|
||||
}
|
||||
|
||||
public function test_logged_in_non_member_gets_default_25_dollar_threshold(): void
|
||||
public function test_logged_in_non_member_gets_guest_threshold(): void
|
||||
{
|
||||
Functions\when('get_current_user_id')->justReturn(42);
|
||||
Functions\when('get_option')->alias(fn($name, $default) => $default);
|
||||
$this->mock_wpdb_is_free_member(false);
|
||||
// No bsc_member row (or a NULL tier_id) -> get_var returns NULL,
|
||||
// same guest fallback as an anonymous visitor.
|
||||
$this->stub_wpdb_with_member_lookup(null, $this->tier_row('guest', ['free_shipping_threshold' => ['threshold' => 25.00]]));
|
||||
|
||||
$this->assertSame(25.00, BSC_Free_Shipping::threshold_for_current_customer());
|
||||
}
|
||||
@@ -46,29 +48,60 @@ class BSCFreeShippingTest extends TestCase
|
||||
public function test_free_member_gets_15_dollar_threshold(): void
|
||||
{
|
||||
Functions\when('get_current_user_id')->justReturn(7);
|
||||
Functions\when('get_option')->alias(fn($name, $default) => $default);
|
||||
$this->mock_wpdb_is_free_member(true);
|
||||
$this->stub_wpdb_with_member_lookup(5, $this->tier_row('free_member', [
|
||||
'free_shipping_threshold' => ['threshold' => 15.00],
|
||||
'spend_discount' => ['min_spend' => 100.00, 'discount_amount' => 10.00],
|
||||
]));
|
||||
|
||||
$this->assertSame(15.00, BSC_Free_Shipping::threshold_for_current_customer());
|
||||
}
|
||||
|
||||
public function test_custom_option_override_is_respected_not_the_hardcoded_default(): void
|
||||
public function test_tier_benefit_value_is_respected_not_a_hardcoded_default(): void
|
||||
{
|
||||
Functions\when('get_current_user_id')->justReturn(0);
|
||||
Functions\expect('get_option')
|
||||
->once()
|
||||
->with('bsc_shipping_threshold_guest', 25.00)
|
||||
->andReturn(29.99);
|
||||
$this->stub_wpdb_anonymous($this->tier_row('guest', ['free_shipping_threshold' => ['threshold' => 29.99]]));
|
||||
|
||||
$this->assertSame(29.99, BSC_Free_Shipping::threshold_for_current_customer());
|
||||
}
|
||||
|
||||
private function mock_wpdb_is_free_member(bool $is_member): void
|
||||
public function test_tier_with_no_shipping_benefit_falls_back_to_25(): void
|
||||
{
|
||||
Functions\when('get_current_user_id')->justReturn(0);
|
||||
$this->stub_wpdb_anonymous($this->tier_row('guest', []));
|
||||
|
||||
$this->assertSame(25.00, BSC_Free_Shipping::threshold_for_current_customer());
|
||||
}
|
||||
|
||||
private function tier_row(string $slug, array $benefits): array
|
||||
{
|
||||
return [
|
||||
'tier_id' => 1,
|
||||
'slug' => $slug,
|
||||
'name' => ucfirst($slug),
|
||||
'sort_order' => 0,
|
||||
'benefits' => json_encode($benefits),
|
||||
];
|
||||
}
|
||||
|
||||
/** user_id 0: Tier::tier_for_user() never calls get_var, only find_by_slug('guest') (one prepare+get_row). */
|
||||
private function stub_wpdb_anonymous(array $resolved_tier_row): void
|
||||
{
|
||||
global $wpdb;
|
||||
$wpdb = Mockery::mock('wpdb');
|
||||
$wpdb->prefix = 'wp_';
|
||||
$wpdb->shouldReceive('prepare')->once()->andReturn('SELECT is_free_member ...');
|
||||
$wpdb->shouldReceive('get_var')->once()->andReturn($is_member ? '1' : '0');
|
||||
$wpdb->shouldReceive('prepare')->once()->andReturn('SQL');
|
||||
$wpdb->shouldReceive('get_var')->never();
|
||||
$wpdb->shouldReceive('get_row')->once()->with('SQL', ARRAY_A)->andReturn($resolved_tier_row);
|
||||
}
|
||||
|
||||
/** A real user_id: one get_var (member lookup) then one get_row (either find() or the guest fallback), both via prepare(). */
|
||||
private function stub_wpdb_with_member_lookup(?int $member_tier_id, array $resolved_tier_row): void
|
||||
{
|
||||
global $wpdb;
|
||||
$wpdb = Mockery::mock('wpdb');
|
||||
$wpdb->prefix = 'wp_';
|
||||
$wpdb->shouldReceive('prepare')->twice()->andReturn('SQL');
|
||||
$wpdb->shouldReceive('get_var')->once()->andReturn($member_tier_id);
|
||||
$wpdb->shouldReceive('get_row')->once()->with('SQL', ARRAY_A)->andReturn($resolved_tier_row);
|
||||
}
|
||||
}
|
||||
@@ -20,3 +20,24 @@ if (!class_exists(\WC_Shipping_Method::class)) {
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists(\WC_Cart::class)) {
|
||||
class WC_Cart
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// Membership\Tier calls $wpdb->get_row(..., ARRAY_A) — these are real
|
||||
// WordPress core constants (wp-includes/wp-db.php) that a full WP
|
||||
// bootstrap would define; this one deliberately doesn't load WordPress,
|
||||
// so they need stubbing here with their real values (get_row()/
|
||||
// get_results() branch on the literal string, not just "is it set").
|
||||
if (!defined('OBJECT')) {
|
||||
define('OBJECT', 'OBJECT');
|
||||
}
|
||||
if (!defined('ARRAY_A')) {
|
||||
define('ARRAY_A', 'ARRAY_A');
|
||||
}
|
||||
if (!defined('ARRAY_N')) {
|
||||
define('ARRAY_N', 'ARRAY_N');
|
||||
}
|
||||
Reference in new issue
Block a user