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:
twooeyandClaude Sonnet 5 committed 2026-08-28 11:56:06 -04:00
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');
}