You've already forked wc-composable-product
Add PHPUnit test suite, PSR-4 refactor, lint+test CI jobs (v1.3.1)
- 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:
@@ -6,7 +6,52 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
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:
|
build-release:
|
||||||
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -18,6 +18,10 @@ logs/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
.directory
|
.directory
|
||||||
|
|
||||||
|
# PHPUnit local overrides
|
||||||
|
phpunit.xml
|
||||||
|
|
||||||
# Binary files
|
# Binary files
|
||||||
languages/*.mo
|
languages/*.mo
|
||||||
|
|
||||||
|
.phpunit.result.cache
|
||||||
|
|||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -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/),
|
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).
|
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
|
## [1.3.0] - 2026-03-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
15
CLAUDE.md
15
CLAUDE.md
@@ -18,6 +18,7 @@ This project is 100% AI-generated ("vibe-coded") using Claude.AI.
|
|||||||
- **Styling:** Custom CSS
|
- **Styling:** Custom CSS
|
||||||
- **Dependencies:** Composer
|
- **Dependencies:** Composer
|
||||||
- **i18n:** WordPress i18n (.pot/.po/.mo), text domain: `wc-composable-product`
|
- **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`)
|
- **CI/CD:** Gitea Actions (`.gitea/workflows/release.yml`)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
@@ -45,10 +46,16 @@ wc-composable-product/
|
|||||||
│ └── StockManager.php # Stock management & inventory tracking
|
│ └── StockManager.php # Stock management & inventory tracking
|
||||||
├── languages/ # Translation files (.pot, .po, .mo)
|
├── languages/ # Translation files (.pot, .po, .mo)
|
||||||
├── releases/ # Release packages (gitignored)
|
├── 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/
|
├── templates/
|
||||||
│ └── product-selector.twig # Frontend selection interface
|
│ └── product-selector.twig # Frontend selection interface
|
||||||
├── vendor/ # Composer dependencies (gitignored, included in releases)
|
├── vendor/ # Composer dependencies (gitignored, included in releases)
|
||||||
├── composer.json
|
├── composer.json
|
||||||
|
├── phpunit.xml.dist # PHPUnit configuration
|
||||||
└── wc-composable-product.php # Main plugin file
|
└── 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.
|
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
|
## Release Workflow
|
||||||
|
|
||||||
### Automated (Gitea CI/CD)
|
### 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
|
### Manual
|
||||||
|
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -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
|
- **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)
|
- **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
|
- **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
|
## Requirements
|
||||||
|
|
||||||
@@ -118,6 +119,16 @@ This project was created with AI assistance (Claude.AI) and follows WordPress an
|
|||||||
composer install
|
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
|
### Translation
|
||||||
|
|
||||||
Generate POT file:
|
Generate POT file:
|
||||||
|
|||||||
@@ -18,6 +18,16 @@
|
|||||||
"Magdev\\WcComposableProduct\\": "includes/"
|
"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": {
|
"config": {
|
||||||
"optimize-autoloader": true,
|
"optimize-autoloader": true,
|
||||||
"sort-packages": true
|
"sort-packages": true
|
||||||
|
|||||||
2065
composer.lock
generated
2065
composer.lock
generated
File diff suppressed because it is too large
Load Diff
24
phpunit.xml.dist
Normal file
24
phpunit.xml.dist
Normal 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
75
tests/TestCase.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
tests/Unit/Admin/ProductDataTest.php
Normal file
108
tests/Unit/Admin/ProductDataTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
tests/Unit/Admin/SettingsTest.php
Normal file
84
tests/Unit/Admin/SettingsTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
184
tests/Unit/CartHandlerTest.php
Normal file
184
tests/Unit/CartHandlerTest.php
Normal 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
72
tests/Unit/PluginTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
218
tests/Unit/ProductTypeTest.php
Normal file
218
tests/Unit/ProductTypeTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
226
tests/Unit/StockManagerTest.php
Normal file
226
tests/Unit/StockManagerTest.php
Normal 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
33
tests/bootstrap.php
Normal 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';
|
||||||
11
tests/stubs/class-wc-admin-settings.php
Normal file
11
tests/stubs/class-wc-admin-settings.php
Normal 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
15
tests/stubs/class-wc-cart.php
Normal file
15
tests/stubs/class-wc-cart.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
tests/stubs/class-wc-data.php
Normal file
35
tests/stubs/class-wc-data.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
tests/stubs/class-wc-order-item-product.php
Normal file
28
tests/stubs/class-wc-order-item-product.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tests/stubs/class-wc-order.php
Normal file
24
tests/stubs/class-wc-order.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
88
tests/stubs/class-wc-product.php
Normal file
88
tests/stubs/class-wc-product.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
tests/stubs/class-wc-settings-page.php
Normal file
29
tests/stubs/class-wc-settings-page.php
Normal 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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* Plugin Name: WooCommerce Composable Products
|
* Plugin Name: WooCommerce Composable Products
|
||||||
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-composable-product
|
* 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
|
* 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: Marco Graetsch
|
||||||
* Author URI: https://src.bundespruefstelle.ch/magdev
|
* Author URI: https://src.bundespruefstelle.ch/magdev
|
||||||
* License: GPL v3 or later
|
* License: GPL v3 or later
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
// Define plugin constants
|
// 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_FILE', __FILE__);
|
||||||
define('WC_COMPOSABLE_PRODUCT_PATH', plugin_dir_path(__FILE__));
|
define('WC_COMPOSABLE_PRODUCT_PATH', plugin_dir_path(__FILE__));
|
||||||
define('WC_COMPOSABLE_PRODUCT_URL', plugin_dir_url(__FILE__));
|
define('WC_COMPOSABLE_PRODUCT_URL', plugin_dir_url(__FILE__));
|
||||||
|
|||||||
Reference in New Issue
Block a user