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
|
||||
vendor/
|
||||
node_modules/
|
||||
.phpunit.result.cache
|
||||
*.log
|
||||
.DS_Store
|
||||
/.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)
|
||||
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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
+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