Add PHPUnit test suite, PSR-4 refactor, lint+test CI jobs (v1.3.1)
Some checks failed
Create Release Package / PHP Lint (push) Successful in 47s
Create Release Package / test (push) Failing after 53s
Create Release Package / build-release (push) Has been skipped

- 57 unit tests covering ProductType, StockManager, CartHandler, Plugin,
  Admin/ProductData, Admin/Settings using Brain Monkey + Mockery
- WooCommerce class stubs for testing without WP installation
- PHP lint and test jobs in release workflow (test gate blocks release)
- PSR-4 namespace change: WC_Composable_Product -> Magdev\WcComposableProduct
- PascalCase filenames for all classes under includes/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 13:08:22 +01:00
parent ea2261d8d7
commit a7d6a57f01
24 changed files with 3415 additions and 12 deletions

View File

@@ -6,7 +6,52 @@ on:
- 'v*'
jobs:
lint:
name: PHP Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, zip
tools: composer:v2
- name: PHP Syntax Check
run: |
find includes -name "*.php" -print0 | xargs -0 -n1 php -l
find tests -name "*.php" -print0 | xargs -0 -n1 php -l
test:
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml
tools: composer:v2
- name: Install Composer dependencies
run: |
composer config platform.php 8.3.0
composer install --optimize-autoloader --no-interaction
- name: Run PHPUnit tests
run: vendor/bin/phpunit --testdox
build-release:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout code

4
.gitignore vendored
View File

@@ -18,6 +18,10 @@ logs/
Thumbs.db
.directory
# PHPUnit local overrides
phpunit.xml
# Binary files
languages/*.mo
.phpunit.result.cache

View File

@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.1] - 2026-03-01
### Added
- **PHPUnit test suite**: 57 unit tests covering all 6 core classes (ProductType, StockManager, CartHandler, Plugin, Admin/ProductData, Admin/Settings)
- **Brain Monkey + Mockery** for WordPress/WooCommerce function mocking without a full WP installation
- **WooCommerce class stubs** in `tests/stubs/` for classes extended by the plugin (WC_Product, WC_Settings_Page, etc.)
- **PHP lint job** in release workflow — syntax-checks all PHP files before testing
- **Test job** in release workflow — tests must pass before release package is built (`needs: test`)
- Testing section in README
### Changed
- **PSR-4 refactored**: Renamed files to PascalCase (Product_Type → ProductType, etc.) and changed namespace from `WC_Composable_Product` to `Magdev\WcComposableProduct`
- Updated all cross-references in PHP files, main plugin file, composer.json, CSS/JS doc comments, and translation file source comments
- Release workflow now has three stages: lint → test → build-release
## [1.3.0] - 2026-03-01
### Added

View File

@@ -18,6 +18,7 @@ This project is 100% AI-generated ("vibe-coded") using Claude.AI.
- **Styling:** Custom CSS
- **Dependencies:** Composer
- **i18n:** WordPress i18n (.pot/.po/.mo), text domain: `wc-composable-product`
- **Testing:** PHPUnit 9.6 + Brain Monkey 2.7 + Mockery 1.6
- **CI/CD:** Gitea Actions (`.gitea/workflows/release.yml`)
## Project Structure
@@ -45,10 +46,16 @@ wc-composable-product/
│ └── StockManager.php # Stock management & inventory tracking
├── languages/ # Translation files (.pot, .po, .mo)
├── releases/ # Release packages (gitignored)
├── tests/
│ ├── bootstrap.php # Test environment setup (constants, stubs)
│ ├── TestCase.php # Base test case with Brain Monkey
│ ├── stubs/ # Minimal WooCommerce class stubs
│ └── Unit/ # PHPUnit unit tests
├── templates/
│ └── product-selector.twig # Frontend selection interface
├── vendor/ # Composer dependencies (gitignored, included in releases)
├── composer.json
├── phpunit.xml.dist # PHPUnit configuration
└── wc-composable-product.php # Main plugin file
```
@@ -133,11 +140,17 @@ Compile .po to .mo: `for po in languages/*.po; do msgfmt -o "${po%.po}.mo" "$po"
WordPress requires compiled .mo files — .po files alone are insufficient.
## Testing
Run unit tests: `vendor/bin/phpunit --testdox`
Tests use **Brain Monkey** to mock WordPress/WooCommerce functions without a full WP installation. WooCommerce classes (`WC_Product`, `WC_Settings_Page`, etc.) are provided as minimal stubs in `tests/stubs/` so PHP can parse `extends` declarations. The release workflow runs tests before building — a failing test blocks the release.
## Release Workflow
### Automated (Gitea CI/CD)
Push an annotated tag (`v*`) to trigger the workflow. It installs PHP 8.3, production Composer deps, compiles translations, verifies version matches tag, creates ZIP with checksums, and publishes a Gitea release.
Push an annotated tag (`v*`) to trigger the workflow. It first runs the PHPUnit test suite, then installs PHP 8.3, production Composer deps, compiles translations, verifies version matches tag, creates ZIP with checksums, and publishes a Gitea release. Tests must pass before the release package is built.
### Manual

View File

@@ -12,7 +12,8 @@ Create composable products where customers can select a limited number of items
- **Pricing Options**: Fixed price or sum of selected products with full locale-aware formatting
- **Multi-language Support**: Fully translated in 6 locales (de_DE, de_CH, fr_CH, it_CH + informal variants)
- **Modern UI**: Clean interface built with Twig templates and vanilla JavaScript
- **CI/CD**: Automated release workflow for Gitea
- **Tested**: 57 unit tests with PHPUnit, Brain Monkey, and Mockery
- **CI/CD**: Automated release workflow with test gate for Gitea
## Requirements
@@ -118,6 +119,16 @@ This project was created with AI assistance (Claude.AI) and follows WordPress an
composer install
```
### Running Tests
The plugin includes a PHPUnit test suite with Brain Monkey for WordPress function mocking:
```bash
vendor/bin/phpunit --testdox
```
Tests run without a WordPress installation. WooCommerce classes are provided as minimal stubs in `tests/stubs/`.
### Translation
Generate POT file:

View File

@@ -18,6 +18,16 @@
"Magdev\\WcComposableProduct\\": "includes/"
}
},
"require-dev": {
"brain/monkey": "^2.7",
"mockery/mockery": "^1.6",
"phpunit/phpunit": "^9.6"
},
"autoload-dev": {
"psr-4": {
"Magdev\\WcComposableProduct\\Tests\\": "tests/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true

2065
composer.lock generated

File diff suppressed because it is too large Load Diff

24
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
verbose="true"
beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutOutputDuringTests="true"
failOnRisky="false"
failOnWarning="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">tests/Unit</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">includes</directory>
</include>
</coverage>
</phpunit>

75
tests/TestCase.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
/**
* Base Test Case
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Brain\Monkey;
/**
* Base test case with Brain Monkey and Mockery integration.
*
* All test classes should extend this instead of PHPUnit\Framework\TestCase.
*/
abstract class TestCase extends PHPUnitTestCase
{
use MockeryPHPUnitIntegration;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
// Stub common WordPress translation functions (__(), _e(), esc_html__(), etc.)
Monkey\Functions\stubTranslationFunctions();
// Stub common WordPress escaping functions (esc_html(), esc_attr(), etc.)
Monkey\Functions\stubEscapeFunctions();
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
/**
* Create a Mockery mock of WC_Product with sensible defaults.
*
* @param array $overrides Method return value overrides
* @return \Mockery\MockInterface
*/
protected function createProductMock(array $overrides = []): \Mockery\MockInterface
{
$defaults = [
'get_id' => 100,
'get_name' => 'Test Product',
'get_type' => 'simple',
'get_price' => '10.00',
'get_regular_price' => '10.00',
'get_price_html' => '<span>$10.00</span>',
'get_permalink' => 'https://example.com/product/test',
'get_image_id' => 1,
'get_stock_quantity' => null,
'get_stock_status' => 'instock',
'is_purchasable' => true,
'is_in_stock' => true,
'managing_stock' => false,
'backorders_allowed' => false,
];
$config = array_merge($defaults, $overrides);
$mock = \Mockery::mock('WC_Product');
foreach ($config as $method => $return) {
$mock->shouldReceive($method)->andReturn($return)->byDefault();
}
return $mock;
}
}

View File

@@ -0,0 +1,108 @@
<?php
/**
* Admin ProductData Tests
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests\Unit\Admin;
use Magdev\WcComposableProduct\Tests\TestCase;
use Magdev\WcComposableProduct\Admin\ProductData;
use Brain\Monkey\Functions;
class ProductDataTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$_POST = [];
}
protected function tearDown(): void
{
$_POST = [];
parent::tearDown();
}
public function testConstructor_RegistersExpectedHooks(): void
{
$productData = new ProductData();
self::assertNotFalse(has_filter('woocommerce_product_data_tabs', 'Magdev\WcComposableProduct\Admin\ProductData->add_product_data_tab()'));
self::assertNotFalse(has_action('woocommerce_product_data_panels', 'Magdev\WcComposableProduct\Admin\ProductData->add_product_data_panel()'));
self::assertNotFalse(has_action('woocommerce_process_product_meta_composable', 'Magdev\WcComposableProduct\Admin\ProductData->save_product_data()'));
self::assertNotFalse(has_action('woocommerce_product_options_general_product_data', 'Magdev\WcComposableProduct\Admin\ProductData->add_general_fields()'));
}
public function testAddProductDataTab_AddsComposableTab(): void
{
$productData = new ProductData();
$tabs = $productData->add_product_data_tab([]);
$this->assertArrayHasKey('composable', $tabs);
$this->assertSame('composable_product_data', $tabs['composable']['target']);
$this->assertContains('show_if_composable', $tabs['composable']['class']);
$this->assertSame(21, $tabs['composable']['priority']);
}
public function testSaveProductData_SavesAllFields(): void
{
$_POST = [
'_composable_selection_limit' => '5',
'_composable_pricing_mode' => 'fixed',
'_composable_include_unpublished' => 'yes',
'_composable_criteria_type' => 'tag',
'_composable_categories' => ['1', '2'],
'_composable_tags' => ['3', '4'],
'_composable_skus' => 'SKU-1, SKU-2',
];
Functions\expect('absint')->andReturnUsing(function ($val) {
return abs((int) $val);
});
Functions\expect('sanitize_text_field')->andReturnUsing(function ($val) {
return $val;
});
Functions\expect('sanitize_textarea_field')->andReturnUsing(function ($val) {
return $val;
});
Functions\expect('update_post_meta')->times(7);
$productData = new ProductData();
$productData->save_product_data(42);
}
public function testSaveProductData_DefaultsWhenPostEmpty(): void
{
// No POST data at all
Functions\expect('absint')->andReturnUsing(function ($val) {
return abs((int) $val);
});
Functions\expect('sanitize_text_field')->andReturnUsing(function ($val) {
return $val;
});
Functions\expect('sanitize_textarea_field')->andReturnUsing(function ($val) {
return $val;
});
Functions\expect('update_post_meta')
->with(42, '_composable_selection_limit', \Mockery::any())->once();
Functions\expect('update_post_meta')
->with(42, '_composable_pricing_mode', '')->once();
Functions\expect('update_post_meta')
->with(42, '_composable_include_unpublished', '')->once();
Functions\expect('update_post_meta')
->with(42, '_composable_criteria_type', 'category')->once();
Functions\expect('update_post_meta')
->with(42, '_composable_categories', [])->once();
Functions\expect('update_post_meta')
->with(42, '_composable_tags', [])->once();
Functions\expect('update_post_meta')
->with(42, '_composable_skus', '')->once();
$productData = new ProductData();
$productData->save_product_data(42);
}
}

View File

@@ -0,0 +1,84 @@
<?php
/**
* Admin Settings Tests
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests\Unit\Admin;
use Magdev\WcComposableProduct\Tests\TestCase;
use Magdev\WcComposableProduct\Admin\Settings;
use Brain\Monkey\Functions;
class SettingsTest extends TestCase
{
public function testConstructor_SetsIdAndLabel(): void
{
$settings = new Settings();
$this->assertSame('composable_products', $settings->get_id());
}
public function testGetSettings_ReturnsExpectedFieldIds(): void
{
Functions\expect('apply_filters')
->once()
->with('wc_composable_settings', \Mockery::type('array'))
->andReturnUsing(function ($hook, $settings) {
return $settings;
});
$settings = new Settings();
$fields = $settings->get_settings();
// Extract all field IDs
$ids = array_column($fields, 'id');
$this->assertContains('wc_composable_settings', $ids);
$this->assertContains('wc_composable_default_limit', $ids);
$this->assertContains('wc_composable_default_pricing', $ids);
$this->assertContains('wc_composable_include_unpublished', $ids);
$this->assertContains('wc_composable_show_images', $ids);
$this->assertContains('wc_composable_show_prices', $ids);
$this->assertContains('wc_composable_show_total', $ids);
}
public function testGetSettings_HasCorrectFieldTypes(): void
{
Functions\expect('apply_filters')
->once()
->andReturnUsing(function ($hook, $settings) {
return $settings;
});
$settings = new Settings();
$fields = $settings->get_settings();
// Index fields by ID for easy lookup
$indexed = [];
foreach ($fields as $field) {
if (isset($field['id'])) {
$indexed[$field['id']] = $field;
}
}
$this->assertSame('number', $indexed['wc_composable_default_limit']['type']);
$this->assertSame('select', $indexed['wc_composable_default_pricing']['type']);
$this->assertSame('checkbox', $indexed['wc_composable_include_unpublished']['type']);
$this->assertSame('checkbox', $indexed['wc_composable_show_images']['type']);
}
public function testGetSettings_AppliesFilter(): void
{
Functions\expect('apply_filters')
->once()
->with('wc_composable_settings', \Mockery::type('array'))
->andReturnUsing(function ($hook, $settings) {
return $settings;
});
$settings = new Settings();
$settings->get_settings();
}
}

View File

@@ -0,0 +1,184 @@
<?php
/**
* CartHandler Tests
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests\Unit;
use Magdev\WcComposableProduct\Tests\TestCase;
use Magdev\WcComposableProduct\CartHandler;
use Brain\Monkey\Functions;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
class CartHandlerTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Clean up POST superglobal between tests
$_POST = [];
}
protected function tearDown(): void
{
$_POST = [];
parent::tearDown();
}
public function testConstructor_RegistersExpectedHooks(): void
{
$handler = new CartHandler();
self::assertNotFalse(has_filter('woocommerce_add_to_cart_validation', 'Magdev\WcComposableProduct\CartHandler->validate_add_to_cart()'));
self::assertNotFalse(has_filter('woocommerce_add_cart_item_data', 'Magdev\WcComposableProduct\CartHandler->add_cart_item_data()'));
self::assertNotFalse(has_filter('woocommerce_get_cart_item_from_session', 'Magdev\WcComposableProduct\CartHandler->get_cart_item_from_session()'));
self::assertNotFalse(has_filter('woocommerce_get_item_data', 'Magdev\WcComposableProduct\CartHandler->display_cart_item_data()'));
self::assertNotFalse(has_action('woocommerce_before_calculate_totals', 'Magdev\WcComposableProduct\CartHandler->calculate_cart_item_price()'));
self::assertNotFalse(has_action('woocommerce_single_product_summary', 'Magdev\WcComposableProduct\CartHandler->render_product_selector()'));
self::assertNotFalse(has_filter('woocommerce_is_purchasable', 'Magdev\WcComposableProduct\CartHandler->hide_default_add_to_cart()'));
}
public function testHideDefaultAddToCart_ReturnsFalseForComposable(): void
{
$handler = new CartHandler();
$product = $this->createProductMock(['get_type' => 'composable']);
$result = $handler->hide_default_add_to_cart(true, $product);
$this->assertFalse($result);
}
public function testHideDefaultAddToCart_PassesThroughForSimple(): void
{
$handler = new CartHandler();
$product = $this->createProductMock(['get_type' => 'simple']);
$result = $handler->hide_default_add_to_cart(true, $product);
$this->assertTrue($result);
}
public function testValidateAddToCart_PassesThroughForNonComposable(): void
{
$product = $this->createProductMock(['get_type' => 'simple']);
Functions\expect('wc_get_product')->with(1)->andReturn($product);
$handler = new CartHandler();
$result = $handler->validate_add_to_cart(true, 1, 1);
$this->assertTrue($result);
}
public function testValidateAddToCart_ReturnsFalseWhenNoProductsSelected(): void
{
$product = $this->createProductMock(['get_type' => 'composable']);
Functions\expect('wc_get_product')->with(1)->andReturn($product);
Functions\expect('wc_add_notice')->once();
$_POST['composable_products'] = [];
$handler = new CartHandler();
$result = $handler->validate_add_to_cart(true, 1, 1);
$this->assertFalse($result);
}
public function testValidateAddToCart_ReturnsFalseWhenNoPostData(): void
{
$product = $this->createProductMock(['get_type' => 'composable']);
Functions\expect('wc_get_product')->with(1)->andReturn($product);
Functions\expect('wc_add_notice')->once();
// No $_POST['composable_products'] at all
$handler = new CartHandler();
$result = $handler->validate_add_to_cart(true, 1, 1);
$this->assertFalse($result);
}
public function testAddCartItemData_AddsSelectionsForComposable(): void
{
$product = $this->createProductMock(['get_type' => 'composable']);
Functions\expect('wc_get_product')->with(1)->andReturn($product);
Functions\expect('absint')->andReturnUsing(function ($val) {
return abs((int) $val);
});
$_POST['composable_products'] = ['101', '102'];
$handler = new CartHandler();
$result = $handler->add_cart_item_data([], 1);
$this->assertArrayHasKey('composable_products', $result);
$this->assertSame([101, 102], $result['composable_products']);
$this->assertArrayHasKey('unique_key', $result);
}
public function testAddCartItemData_PassesThroughForNonComposable(): void
{
$product = $this->createProductMock(['get_type' => 'simple']);
Functions\expect('wc_get_product')->with(1)->andReturn($product);
$handler = new CartHandler();
$result = $handler->add_cart_item_data(['existing' => 'data'], 1);
$this->assertSame(['existing' => 'data'], $result);
}
public function testGetCartItemFromSession_RestoresComposableProducts(): void
{
$handler = new CartHandler();
$result = $handler->get_cart_item_from_session(
['data' => 'test'],
['composable_products' => [101, 102]]
);
$this->assertSame([101, 102], $result['composable_products']);
}
public function testGetCartItemFromSession_PassesThroughWithoutComposableData(): void
{
$handler = new CartHandler();
$result = $handler->get_cart_item_from_session(
['data' => 'test'],
[]
);
$this->assertArrayNotHasKey('composable_products', $result);
}
public function testDisplayCartItemData_FormatsProductNames(): void
{
$mock1 = $this->createProductMock(['get_name' => 'Product A']);
$mock2 = $this->createProductMock(['get_name' => 'Product B']);
Functions\expect('wc_get_product')
->andReturnUsing(function ($id) use ($mock1, $mock2) {
return match ($id) {
101 => $mock1,
102 => $mock2,
default => false,
};
});
$handler = new CartHandler();
$result = $handler->display_cart_item_data(
[],
['composable_products' => [101, 102]]
);
$this->assertCount(1, $result);
$this->assertStringContainsString('Product A', $result[0]['value']);
$this->assertStringContainsString('Product B', $result[0]['value']);
}
public function testDisplayCartItemData_ReturnsEmptyForNonComposable(): void
{
$handler = new CartHandler();
$result = $handler->display_cart_item_data([], []);
$this->assertSame([], $result);
}
}

72
tests/Unit/PluginTest.php Normal file
View File

@@ -0,0 +1,72 @@
<?php
/**
* Plugin Tests
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests\Unit;
use Magdev\WcComposableProduct\Tests\TestCase;
use Magdev\WcComposableProduct\Plugin;
use Brain\Monkey\Functions;
class PluginTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Reset the singleton instance between tests
$reflection = new \ReflectionClass(Plugin::class);
$property = $reflection->getProperty('instance');
$property->setAccessible(true);
$property->setValue(null, null);
}
public function testInstance_ReturnsSingleton(): void
{
$instance1 = Plugin::instance();
$instance2 = Plugin::instance();
$this->assertSame($instance1, $instance2);
}
public function testInstance_ReturnsPluginClass(): void
{
$instance = Plugin::instance();
$this->assertInstanceOf(Plugin::class, $instance);
}
public function testAddProductType_AddsComposableToTypes(): void
{
$plugin = Plugin::instance();
$types = $plugin->add_product_type([]);
$this->assertArrayHasKey('composable', $types);
}
public function testProductClass_ReturnsCustomClassForComposable(): void
{
$plugin = Plugin::instance();
$class = $plugin->product_class('WC_Product', 'composable');
$this->assertSame('Magdev\WcComposableProduct\ProductType', $class);
}
public function testProductClass_PassesThroughForOtherTypes(): void
{
$plugin = Plugin::instance();
$class = $plugin->product_class('WC_Product', 'simple');
$this->assertSame('WC_Product', $class);
}
public function testGetTwig_ReturnsTwigEnvironment(): void
{
$plugin = Plugin::instance();
$this->assertInstanceOf(\Twig\Environment::class, $plugin->get_twig());
}
}

View File

@@ -0,0 +1,218 @@
<?php
/**
* ProductType Tests
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests\Unit;
use Magdev\WcComposableProduct\Tests\TestCase;
use Magdev\WcComposableProduct\ProductType;
use Brain\Monkey\Functions;
class ProductTypeTest extends TestCase
{
private function createProductType(array $meta = []): ProductType
{
$product = new ProductType();
foreach ($meta as $key => $value) {
$product->update_meta_data($key, $value);
}
return $product;
}
public function testGetType_ReturnsComposable(): void
{
$product = $this->createProductType();
$this->assertSame('composable', $product->get_type());
}
public function testIsPurchasable_ReturnsTrue(): void
{
$product = $this->createProductType();
$this->assertTrue($product->is_purchasable());
}
public function testIsSoldIndividually_ReturnsTrue(): void
{
$product = $this->createProductType();
$this->assertTrue($product->is_sold_individually());
}
public function testGetSelectionLimit_UsesProductMeta(): void
{
Functions\expect('absint')->once()->andReturnUsing(function ($val) {
return abs((int) $val);
});
$product = $this->createProductType(['_composable_selection_limit' => '3']);
$this->assertSame(3, $product->get_selection_limit());
}
public function testGetSelectionLimit_FallsBackToGlobalDefault(): void
{
Functions\expect('get_option')
->once()
->with('wc_composable_default_limit', 5)
->andReturn(7);
Functions\expect('absint')->once()->andReturnUsing(function ($val) {
return abs((int) $val);
});
$product = $this->createProductType();
$this->assertSame(7, $product->get_selection_limit());
}
public function testGetSelectionLimit_FallsBackToHardDefault(): void
{
Functions\expect('get_option')
->once()
->with('wc_composable_default_limit', 5)
->andReturn(5);
Functions\expect('absint')->once()->andReturnUsing(function ($val) {
return abs((int) $val);
});
$product = $this->createProductType();
$this->assertSame(5, $product->get_selection_limit());
}
public function testGetPricingMode_UsesProductMeta(): void
{
$product = $this->createProductType(['_composable_pricing_mode' => 'fixed']);
$this->assertSame('fixed', $product->get_pricing_mode());
}
public function testGetPricingMode_FallsBackToGlobalDefault(): void
{
Functions\expect('get_option')
->once()
->with('wc_composable_default_pricing', 'sum')
->andReturn('sum');
$product = $this->createProductType();
$this->assertSame('sum', $product->get_pricing_mode());
}
public function testShouldIncludeUnpublished_PerProductYes(): void
{
$product = $this->createProductType(['_composable_include_unpublished' => 'yes']);
$this->assertTrue($product->should_include_unpublished());
}
public function testShouldIncludeUnpublished_PerProductNo(): void
{
$product = $this->createProductType(['_composable_include_unpublished' => 'no']);
$this->assertFalse($product->should_include_unpublished());
}
public function testShouldIncludeUnpublished_FallsBackToGlobalYes(): void
{
Functions\expect('get_option')
->once()
->with('wc_composable_include_unpublished', 'no')
->andReturn('yes');
$product = $this->createProductType();
$this->assertTrue($product->should_include_unpublished());
}
public function testShouldIncludeUnpublished_FallsBackToGlobalNo(): void
{
Functions\expect('get_option')
->once()
->with('wc_composable_include_unpublished', 'no')
->andReturn('no');
$product = $this->createProductType();
$this->assertFalse($product->should_include_unpublished());
}
public function testGetSelectionCriteria_DefaultsToCategory(): void
{
$product = $this->createProductType();
$criteria = $product->get_selection_criteria();
$this->assertSame('category', $criteria['type']);
$this->assertSame([], $criteria['categories']);
$this->assertSame([], $criteria['tags']);
$this->assertSame('', $criteria['skus']);
}
public function testGetSelectionCriteria_UsesProductMeta(): void
{
$product = $this->createProductType([
'_composable_criteria_type' => 'tag',
'_composable_tags' => [5, 10],
]);
$criteria = $product->get_selection_criteria();
$this->assertSame('tag', $criteria['type']);
$this->assertSame([5, 10], $criteria['tags']);
}
public function testCalculateComposedPrice_FixedMode(): void
{
$product = $this->createProductType(['_composable_pricing_mode' => 'fixed']);
// Set regular price via the stub's data property
$reflection = new \ReflectionClass($product);
$dataProp = $reflection->getProperty('data');
$dataProp->setAccessible(true);
$data = $dataProp->getValue($product);
$data['regular_price'] = '25.00';
$dataProp->setValue($product, $data);
$price = $product->calculate_composed_price([101, 102]);
$this->assertSame(25.0, $price);
}
public function testCalculateComposedPrice_SumMode(): void
{
Functions\expect('get_option')
->with('wc_composable_default_pricing', 'sum')
->andReturn('sum');
$mock1 = $this->createProductMock(['get_price' => '5.00']);
$mock2 = $this->createProductMock(['get_price' => '7.50']);
Functions\expect('wc_get_product')
->andReturnUsing(function ($id) use ($mock1, $mock2) {
return match ($id) {
101 => $mock1,
102 => $mock2,
default => false,
};
});
$product = $this->createProductType();
$price = $product->calculate_composed_price([101, 102]);
$this->assertSame(12.5, $price);
}
public function testCalculateComposedPrice_SumMode_SkipsInvalidProducts(): void
{
Functions\expect('get_option')
->with('wc_composable_default_pricing', 'sum')
->andReturn('sum');
$mock1 = $this->createProductMock(['get_price' => '5.00']);
Functions\expect('wc_get_product')
->andReturnUsing(function ($id) use ($mock1) {
return match ($id) {
101 => $mock1,
default => false,
};
});
$product = $this->createProductType();
$price = $product->calculate_composed_price([101, 999]);
$this->assertSame(5.0, $price);
}
}

View File

@@ -0,0 +1,226 @@
<?php
/**
* StockManager Tests
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests\Unit;
use Magdev\WcComposableProduct\Tests\TestCase;
use Magdev\WcComposableProduct\StockManager;
use Brain\Monkey\Functions;
class StockManagerTest extends TestCase
{
private StockManager $manager;
protected function setUp(): void
{
parent::setUp();
$this->manager = new StockManager();
}
// --- validate_stock_availability ---
public function testValidateStock_ReturnsTrueWhenAllInStock(): void
{
$mock = $this->createProductMock([
'managing_stock' => false,
'is_in_stock' => true,
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
Functions\expect('wc_get_product')->with(2)->andReturn($mock);
$result = $this->manager->validate_stock_availability([1, 2]);
$this->assertTrue($result);
}
public function testValidateStock_ReturnsTrueWhenNotManagingStock(): void
{
$mock = $this->createProductMock(['managing_stock' => false]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$result = $this->manager->validate_stock_availability([1]);
$this->assertTrue($result);
}
public function testValidateStock_ReturnsErrorForOutOfStock(): void
{
$mock = $this->createProductMock([
'managing_stock' => true,
'is_in_stock' => false,
'get_name' => 'Widget',
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$result = $this->manager->validate_stock_availability([1]);
$this->assertIsString($result);
$this->assertStringContainsString('Widget', $result);
}
public function testValidateStock_ReturnsErrorForInsufficientQuantity(): void
{
$mock = $this->createProductMock([
'managing_stock' => true,
'is_in_stock' => true,
'get_stock_quantity' => 1,
'get_name' => 'Widget',
'backorders_allowed' => false,
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$result = $this->manager->validate_stock_availability([1], 5);
$this->assertIsString($result);
$this->assertStringContainsString('Widget', $result);
}
public function testValidateStock_PassesWhenBackordersAllowed(): void
{
// When stock_quantity is null the insufficient-stock check is skipped,
// and the backorders_allowed() branch is reached.
$mock = $this->createProductMock([
'managing_stock' => true,
'is_in_stock' => true,
'get_stock_quantity' => null,
'backorders_allowed' => true,
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$result = $this->manager->validate_stock_availability([1], 5);
$this->assertTrue($result);
}
public function testValidateStock_SkipsNullProducts(): void
{
Functions\expect('wc_get_product')->with(999)->andReturn(false);
$result = $this->manager->validate_stock_availability([999]);
$this->assertTrue($result);
}
// --- get_product_stock_info ---
public function testGetProductStockInfo_ReturnsCorrectStructure(): void
{
$mock = $this->createProductMock([
'is_in_stock' => true,
'get_stock_quantity' => 10,
'backorders_allowed' => false,
'get_stock_status' => 'instock',
'managing_stock' => true,
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$info = $this->manager->get_product_stock_info(1);
$this->assertTrue($info['in_stock']);
$this->assertSame(10, $info['stock_quantity']);
$this->assertFalse($info['backorders_allowed']);
$this->assertSame('instock', $info['stock_status']);
$this->assertTrue($info['managing_stock']);
$this->assertTrue($info['has_enough_stock']);
}
public function testGetProductStockInfo_ReturnsFallbackForInvalidProduct(): void
{
Functions\expect('wc_get_product')->with(999)->andReturn(false);
$info = $this->manager->get_product_stock_info(999);
$this->assertFalse($info['in_stock']);
$this->assertSame(0, $info['stock_quantity']);
$this->assertFalse($info['backorders_allowed']);
$this->assertSame('outofstock', $info['stock_status']);
}
public function testGetProductStockInfo_HasEnoughStockTrueWhenNotManaging(): void
{
$mock = $this->createProductMock([
'managing_stock' => false,
'get_stock_quantity' => null,
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$info = $this->manager->get_product_stock_info(1, 100);
$this->assertTrue($info['has_enough_stock']);
}
public function testGetProductStockInfo_HasEnoughStockFalseWhenInsufficient(): void
{
$mock = $this->createProductMock([
'managing_stock' => true,
'get_stock_quantity' => 2,
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$info = $this->manager->get_product_stock_info(1, 5);
$this->assertFalse($info['has_enough_stock']);
}
// --- prevent_composable_stock_reduction ---
public function testPreventStockReduction_ReturnsFalseForComposableItem(): void
{
$productMock = $this->createProductMock(['get_type' => 'composable']);
$itemMock = \Mockery::mock('WC_Order_Item_Product');
$itemMock->shouldReceive('get_product')->andReturn($productMock);
$orderMock = \Mockery::mock('WC_Order');
$orderMock->shouldReceive('get_items')->andReturn([$itemMock]);
$result = $this->manager->prevent_composable_stock_reduction(true, $orderMock);
$this->assertFalse($result);
}
public function testPreventStockReduction_PassesThroughForNonComposable(): void
{
$productMock = $this->createProductMock(['get_type' => 'simple']);
$itemMock = \Mockery::mock('WC_Order_Item_Product');
$itemMock->shouldReceive('get_product')->andReturn($productMock);
$orderMock = \Mockery::mock('WC_Order');
$orderMock->shouldReceive('get_items')->andReturn([$itemMock]);
$result = $this->manager->prevent_composable_stock_reduction(true, $orderMock);
$this->assertTrue($result);
}
// --- store_selected_products_in_order ---
public function testStoreSelectedProducts_AddsMetaWhenPresent(): void
{
$itemMock = \Mockery::mock('WC_Order_Item_Product');
$itemMock->shouldReceive('add_meta_data')
->once()
->with('_composable_products', [1, 2], true);
$this->manager->store_selected_products_in_order(
$itemMock,
'cart_key',
['composable_products' => [1, 2]]
);
}
public function testStoreSelectedProducts_DoesNothingWithoutData(): void
{
$itemMock = \Mockery::mock('WC_Order_Item_Product');
$itemMock->shouldNotReceive('add_meta_data');
$this->manager->store_selected_products_in_order(
$itemMock,
'cart_key',
[]
);
}
}

33
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
/**
* PHPUnit Bootstrap
*
* Sets up WooCommerce stubs, plugin constants, and the Composer autoloader
* for running unit tests without a full WordPress installation.
*
* @package Magdev\WcComposableProduct\Tests
*/
// Composer autoloader (loads Brain Monkey, Mockery, plugin classes)
require_once dirname(__DIR__) . '/vendor/autoload.php';
// Define WordPress constants that the plugin expects
defined('ABSPATH') || define('ABSPATH', '/tmp/wordpress/');
defined('WP_DEBUG') || define('WP_DEBUG', true);
defined('DOING_AJAX') || define('DOING_AJAX', false);
// Define plugin constants
defined('WC_COMPOSABLE_PRODUCT_VERSION') || define('WC_COMPOSABLE_PRODUCT_VERSION', '1.3.1');
defined('WC_COMPOSABLE_PRODUCT_FILE') || define('WC_COMPOSABLE_PRODUCT_FILE', dirname(__DIR__) . '/wc-composable-product.php');
defined('WC_COMPOSABLE_PRODUCT_PATH') || define('WC_COMPOSABLE_PRODUCT_PATH', dirname(__DIR__) . '/');
defined('WC_COMPOSABLE_PRODUCT_URL') || define('WC_COMPOSABLE_PRODUCT_URL', 'https://example.com/wp-content/plugins/wc-composable-product/');
defined('WC_COMPOSABLE_PRODUCT_BASENAME') || define('WC_COMPOSABLE_PRODUCT_BASENAME', 'wc-composable-product/wc-composable-product.php');
// Load WooCommerce class stubs (parent before child)
require_once __DIR__ . '/stubs/class-wc-data.php';
require_once __DIR__ . '/stubs/class-wc-product.php';
require_once __DIR__ . '/stubs/class-wc-settings-page.php';
require_once __DIR__ . '/stubs/class-wc-order.php';
require_once __DIR__ . '/stubs/class-wc-order-item-product.php';
require_once __DIR__ . '/stubs/class-wc-cart.php';
require_once __DIR__ . '/stubs/class-wc-admin-settings.php';

View File

@@ -0,0 +1,11 @@
<?php
/**
* Minimal WC_Admin_Settings stub for unit testing.
*/
class WC_Admin_Settings {
public static function output_fields($options) {
}
public static function save_fields($options) {
}
}

View File

@@ -0,0 +1,15 @@
<?php
/**
* Minimal WC_Cart stub for unit testing.
*/
class WC_Cart {
protected $cart_contents = [];
public function get_cart() {
return $this->cart_contents;
}
public function set_cart_contents($contents) {
$this->cart_contents = $contents;
}
}

View File

@@ -0,0 +1,35 @@
<?php
/**
* Minimal WC_Data stub for unit testing.
*/
class WC_Data {
protected $id = 0;
protected $data = [];
protected $meta_data = [];
public function __construct($id = 0) {
if (is_numeric($id) && $id > 0) {
$this->id = (int) $id;
}
}
public function get_id() {
return $this->id;
}
public function set_id($id) {
$this->id = (int) $id;
}
public function get_meta($key, $single = true, $context = 'view') {
return $this->meta_data[$key] ?? ($single ? '' : []);
}
public function update_meta_data($key, $value, $meta_id = 0) {
$this->meta_data[$key] = $value;
}
public function save() {
return $this->get_id();
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* Minimal WC_Order_Item_Product stub for unit testing.
*/
class WC_Order_Item_Product extends WC_Data {
protected $product = null;
protected $quantity = 1;
public function get_product() {
return $this->product;
}
public function set_product($product) {
$this->product = $product;
}
public function get_quantity() {
return $this->quantity;
}
public function set_quantity($quantity) {
$this->quantity = $quantity;
}
public function add_meta_data($key, $value, $unique = false) {
$this->meta_data[$key] = $value;
}
}

View File

@@ -0,0 +1,24 @@
<?php
/**
* Minimal WC_Order stub for unit testing.
*/
class WC_Order extends WC_Data {
protected $items = [];
protected $order_notes = [];
public function get_items($type = '') {
return $this->items;
}
public function set_items($items) {
$this->items = $items;
}
public function add_order_note($note) {
$this->order_notes[] = $note;
}
public function get_order_notes() {
return $this->order_notes;
}
}

View File

@@ -0,0 +1,88 @@
<?php
/**
* Minimal WC_Product stub for unit testing.
*/
class WC_Product extends WC_Data {
protected $supports = [];
protected $data = [
'name' => '',
'price' => '',
'regular_price' => '',
'status' => 'publish',
];
public function get_type() {
return 'simple';
}
public function get_name($context = 'view') {
return $this->data['name'] ?? '';
}
public function get_price($context = 'view') {
return $this->data['price'] ?? '';
}
public function get_regular_price($context = 'view') {
return $this->data['regular_price'] ?? '';
}
public function get_price_html() {
return '';
}
public function get_permalink() {
return '';
}
public function get_image_id($context = 'view') {
return 0;
}
public function get_stock_quantity($context = 'view') {
return null;
}
public function get_stock_status($context = 'view') {
return 'instock';
}
public function get_children() {
return [];
}
public function is_type($type) {
return $this->get_type() === $type;
}
public function is_purchasable() {
return true;
}
public function is_in_stock() {
return true;
}
public function managing_stock() {
return false;
}
public function backorders_allowed() {
return false;
}
public function is_sold_individually() {
return false;
}
public function set_price($price) {
$this->data['price'] = $price;
}
public function set_stock_quantity($quantity) {
}
public function supports($feature) {
return in_array($feature, $this->supports, true);
}
}

View File

@@ -0,0 +1,29 @@
<?php
/**
* Minimal WC_Settings_Page stub for unit testing.
*/
class WC_Settings_Page {
protected $id = '';
protected $label = '';
public function __construct() {
}
public function get_id() {
return $this->id;
}
public function get_label() {
return $this->label;
}
public function get_settings() {
return [];
}
public function output() {
}
public function save() {
}
}

View File

@@ -4,7 +4,7 @@
* Plugin Name: WooCommerce Composable Products
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-composable-product
* Description: Create composable products where customers select a limited number of items from a configurable set
* Version: 1.3.0
* Version: 1.3.1
* Author: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL v3 or later
@@ -20,7 +20,7 @@
defined('ABSPATH') || exit;
// Define plugin constants
define('WC_COMPOSABLE_PRODUCT_VERSION', '1.3.0');
define('WC_COMPOSABLE_PRODUCT_VERSION', '1.3.1');
define('WC_COMPOSABLE_PRODUCT_FILE', __FILE__);
define('WC_COMPOSABLE_PRODUCT_PATH', plugin_dir_path(__FILE__));
define('WC_COMPOSABLE_PRODUCT_URL', plugin_dir_url(__FILE__));