Add automated tests for the 25% pricing formula and $25/$15 shipping thresholds

Launch gate (design doc §5/§8) requires automated tests for both; the repo
had zero test infra until now. PHPUnit + Brain Monkey, run via `make test`
against the existing wordpress dev container.

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 10:58:29 -04:00
1 parent a733ba4bfd
commit 5d8533f290
9 files changed
+2143 -1

No files matched your search

+1
View File
@@ -7,6 +7,7 @@
/.poll-deploy-*.lock
vendor/
node_modules/
.phpunit.result.cache
*.log
.DS_Store
/.claude/
+12 -1
View File
@@ -21,7 +21,7 @@ $(shell grep -Ev '^(DB_PASSWORD|DB_ROOT_PASSWORD)=' .env.$(ENV) > $(COMPOSE_ENV_
COMPOSE = docker compose -p bookstore-$(ENV) -f docker-compose.yml -f docker-compose.$(ENV).yml --env-file $(COMPOSE_ENV_FILE)
CLEANUP = trap 'rm -f $(COMPOSE_ENV_FILE)' EXIT;
.PHONY: up down ps logs shell wp deploy backup
.PHONY: up down ps logs shell wp test deploy backup
up:
$(CLEANUP) $(COMPOSE) up -d --build
@@ -43,6 +43,17 @@ shell:
wp:
$(CLEANUP) $(COMPOSE) exec -u www-data wordpress wp $(ARGS)
# bookstore-core's PHPUnit suite; composer install is idempotent so this
# stays a single target rather than a separate "test-install" step. The
# plugin dir is bind-mounted from the host at the host user's uid, which
# www-data (uid 33 in the container) can't write into — install as root
# (same "-u root yourself for one-off root debugging" pattern as `shell`
# above), then chown vendor/composer.lock back to the host uid/gid so
# they stay normal host-owned files afterward, not root-owned.
test:
$(CLEANUP) $(COMPOSE) exec -u root wordpress bash -c \
"cd wp-content/plugins/bookstore-core && composer install --no-cache && chown -R $(shell id -u):$(shell id -g) vendor composer.lock && vendor/bin/phpunit"
deploy:
$(CLEANUP) ./deploy/deploy.sh $(ENV)
@@ -5,9 +5,18 @@
"require": {
"php": ">=8.1"
},
"require-dev": {
"phpunit/phpunit": "^10",
"brain/monkey": "^2.6"
},
"autoload": {
"psr-4": {
"Bookstore\\Core\\": "includes/"
}
},
"autoload-dev": {
"psr-4": {
"Bookstore\\Core\\Tests\\": "tests/"
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
</phpunit>
@@ -0,0 +1,21 @@
<?php
namespace Bookstore\Core\Tests;
use Brain\Monkey;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
abstract class TestCase extends PHPUnitTestCase
{
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
}
@@ -0,0 +1,65 @@
<?php
namespace Bookstore\Core\Tests\Unit\Pricing;
use Bookstore\Core\Pricing\PricingEngine;
use Bookstore\Core\Tests\TestCase;
/**
* Launch gate: "25% pricing logic ... have automated tests" (design doc
* §5/§8). calculate_retail_price()/round_to_99()/estimate_processing_cost()
* are pure math with no WordPress/WooCommerce dependency, so no mocking
* is needed here.
*/
class PricingEngineTest extends TestCase
{
/**
* @dataProvider retailPriceCases
*/
public function test_calculate_retail_price_applies_25_percent_margin_formula(
float $supplier_base,
float $supplier_shipping,
float $expected
): void {
$actual = PricingEngine::calculate_retail_price($supplier_base, $supplier_shipping);
$this->assertEqualsWithDelta($expected, $actual, 0.001);
}
public static function retailPriceCases(): array
{
return [
'small order' => [10.00, 3.00, 18.99],
'no supplier shipping' => [5.00, 0.00, 7.99],
'near a .99 boundary' => [14.20, 0.05, 20.99],
'zero supplier cost still covers fixed processing' => [0.00, 0.00, 0.99],
'larger order' => [100.00, 10.00, 152.99],
];
}
/**
* @dataProvider roundTo99Cases
*/
public function test_round_to_99_never_rounds_below_the_input(float $price, float $expected): void
{
$this->assertEqualsWithDelta($expected, PricingEngine::round_to_99($price), 0.001);
}
public static function roundTo99Cases(): array
{
return [
// The case the class's own docblock calls out: the old
// ceil($price) - 0.01 formula gave 20.99 here, which is
// below the $20.995 input and would erode the target margin.
'fractional price above the X.99 ending' => [20.995, 21.99],
'exact integer input' => [18.00, 18.99],
'already exactly X.99' => [7.99, 7.99],
'just above X.99' => [8.00, 8.99],
];
}
public function test_estimate_processing_cost(): void
{
$this->assertEqualsWithDelta(0.83, PricingEngine::estimate_processing_cost(20.99), 0.001);
$this->assertEqualsWithDelta(3.00, PricingEngine::estimate_processing_cost(100.00), 0.001);
}
}
@@ -0,0 +1,74 @@
<?php
namespace Bookstore\Core\Tests\Unit\Shipping;
use Bookstore\Core\Shipping\BSC_Free_Shipping;
use Bookstore\Core\Tests\TestCase;
use Brain\Monkey\Functions;
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.
*/
class BSCFreeShippingTest extends TestCase
{
protected function tearDown(): void
{
global $wpdb;
$wpdb = null;
parent::tearDown();
}
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);
$this->assertSame(25.00, BSC_Free_Shipping::threshold_for_current_customer());
}
public function test_logged_in_non_member_gets_default_25_dollar_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);
$this->assertSame(25.00, BSC_Free_Shipping::threshold_for_current_customer());
}
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->assertSame(15.00, BSC_Free_Shipping::threshold_for_current_customer());
}
public function test_custom_option_override_is_respected_not_the_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->assertSame(29.99, BSC_Free_Shipping::threshold_for_current_customer());
}
private function mock_wpdb_is_free_member(bool $is_member): 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');
}
}
@@ -0,0 +1,22 @@
<?php
/**
* Unit-test bootstrap: pure PHPUnit + Brain Monkey, not a full WordPress
* bootstrap. includes/*.php all guard on `defined('ABSPATH') || exit;`
* at file-load time, and BSC_Free_Shipping extends \WC_Shipping_Method
* (a WooCommerce core class) — both need to exist before Composer's
* autoloader ever pulls those files in, hence defining/stubbing them
* here rather than per test case.
*/
require __DIR__ . '/../vendor/autoload.php';
if (!defined('ABSPATH')) {
define('ABSPATH', __DIR__ . '/');
}
if (!class_exists(\WC_Shipping_Method::class)) {
class WC_Shipping_Method
{
}
}