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:
1 parent
a733ba4bfd
commit
5d8533f290
9 files changed
+2143
-1
No files matched your search
@@ -7,6 +7,7 @@
|
|||||||
/.poll-deploy-*.lock
|
/.poll-deploy-*.lock
|
||||||
vendor/
|
vendor/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.phpunit.result.cache
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/.claude/
|
/.claude/
|
||||||
@@ -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)
|
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;
|
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:
|
up:
|
||||||
$(CLEANUP) $(COMPOSE) up -d --build
|
$(CLEANUP) $(COMPOSE) up -d --build
|
||||||
@@ -43,6 +43,17 @@ shell:
|
|||||||
wp:
|
wp:
|
||||||
$(CLEANUP) $(COMPOSE) exec -u www-data wordpress wp $(ARGS)
|
$(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:
|
deploy:
|
||||||
$(CLEANUP) ./deploy/deploy.sh $(ENV)
|
$(CLEANUP) ./deploy/deploy.sh $(ENV)
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,18 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.1"
|
"php": ">=8.1"
|
||||||
},
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^10",
|
||||||
|
"brain/monkey": "^2.6"
|
||||||
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Bookstore\\Core\\": "includes/"
|
"Bookstore\\Core\\": "includes/"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Bookstore\\Core\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1928
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
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user