You've already forked wc-bootstrap
Add PHPUnit test suite with Brain\Monkey (v0.1.6)
Add test infrastructure for isolated unit testing without WordPress/WooCommerce: - 27 tests (54 assertions) covering TemplateOverride and WooCommerceExtension - Brain\Monkey for WordPress function mocking, class stubs for TwigService and WC_Product - PHPUnit test job added to Gitea CI pipeline between lint and build-release - Test artifacts excluded from release packages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
190
tests/Unit/TemplateOverrideTest.php
Normal file
190
tests/Unit/TemplateOverrideTest.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WcBootstrap\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
use WcBootstrap\TemplateOverride;
|
||||
use WPBootstrap\Twig\TwigService;
|
||||
|
||||
class TemplateOverrideTest extends TestCase
|
||||
{
|
||||
private TemplateOverride $override;
|
||||
private string $templatePath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
TwigService::reset();
|
||||
|
||||
// WC_BOOTSTRAP_PATH must point to the theme root so
|
||||
// resolveTwigTemplate() can locate files under templates/.
|
||||
$this->templatePath = dirname(__DIR__, 2) . '/';
|
||||
if (!defined('WC_BOOTSTRAP_PATH')) {
|
||||
define('WC_BOOTSTRAP_PATH', $this->templatePath);
|
||||
}
|
||||
|
||||
$this->override = new TemplateOverride();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
TwigService::reset();
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── register() ──────────────────────────────────────────────
|
||||
|
||||
public function testRegisterAddsHooksWhenTwigServiceExists(): void
|
||||
{
|
||||
$calls = [];
|
||||
Functions\when('add_action')->alias(function () use (&$calls): void {
|
||||
$calls[] = func_get_args();
|
||||
});
|
||||
|
||||
$this->override->register();
|
||||
|
||||
$this->assertCount(2, $calls);
|
||||
$this->assertSame('woocommerce_before_template_part', $calls[0][0]);
|
||||
$this->assertSame('woocommerce_after_template_part', $calls[1][0]);
|
||||
}
|
||||
|
||||
// ── beforeTemplatePart / afterTemplatePart ───────────────────
|
||||
|
||||
public function testBeforeTemplatePartRendersAndBuffersWhenTwigTemplateExists(): void
|
||||
{
|
||||
// Use a real template file that exists in the theme.
|
||||
// cart/cart.php -> cart/cart.html.twig
|
||||
$templateName = 'cart/cart.php';
|
||||
|
||||
TwigService::setRenderCallback(function (string $tpl, array $ctx): string {
|
||||
return '<div>twig-rendered</div>';
|
||||
});
|
||||
|
||||
$this->override->beforeTemplatePart($templateName, '', '', []);
|
||||
|
||||
// Output buffer should be active — the PHP template output is being captured.
|
||||
$level = ob_get_level();
|
||||
$this->assertGreaterThan(0, $level);
|
||||
|
||||
// Now simulate the PHP template echoing something.
|
||||
echo 'php-output-should-be-discarded';
|
||||
|
||||
$this->override->afterTemplatePart($templateName, '', '', []);
|
||||
|
||||
// The Twig output was already echoed before the buffer, so we
|
||||
// just verify the buffer was cleaned (level decreased).
|
||||
$this->assertSame($level - 1, ob_get_level());
|
||||
}
|
||||
|
||||
public function testBeforeTemplatePartSkipsWhenNoTwigTemplate(): void
|
||||
{
|
||||
$levelBefore = ob_get_level();
|
||||
|
||||
// Use a template name that has no Twig override.
|
||||
$this->override->beforeTemplatePart('nonexistent/template.php', '', '', []);
|
||||
|
||||
// No buffer should have been started.
|
||||
$this->assertSame($levelBefore, ob_get_level());
|
||||
}
|
||||
|
||||
public function testAfterTemplatePartIgnoresUnbufferedTemplate(): void
|
||||
{
|
||||
$levelBefore = ob_get_level();
|
||||
|
||||
// Calling after without a matching before should be safe.
|
||||
$this->override->afterTemplatePart('cart/cart.php', '', '', []);
|
||||
|
||||
$this->assertSame($levelBefore, ob_get_level());
|
||||
}
|
||||
|
||||
public function testNestedTemplatesHandledCorrectly(): void
|
||||
{
|
||||
// Both templates must exist as Twig files.
|
||||
$outer = 'cart/cart.php';
|
||||
$inner = 'cart/cart-empty.php';
|
||||
|
||||
TwigService::setRenderCallback(fn() => '<div>rendered</div>');
|
||||
|
||||
$levelBefore = ob_get_level();
|
||||
|
||||
$this->override->beforeTemplatePart($outer, '', '', []);
|
||||
$outerLevel = ob_get_level();
|
||||
|
||||
$this->override->beforeTemplatePart($inner, '', '', []);
|
||||
$innerLevel = ob_get_level();
|
||||
|
||||
$this->assertSame($outerLevel + 1, $innerLevel);
|
||||
|
||||
// Close inner first (stack order).
|
||||
$this->override->afterTemplatePart($inner, '', '', []);
|
||||
$this->assertSame($outerLevel, ob_get_level());
|
||||
|
||||
$this->override->afterTemplatePart($outer, '', '', []);
|
||||
$this->assertSame($levelBefore, ob_get_level());
|
||||
}
|
||||
|
||||
public function testBeforeTemplatePartPassesArgsAndProductToTwig(): void
|
||||
{
|
||||
$templateName = 'cart/cart.php';
|
||||
$captured = null;
|
||||
|
||||
TwigService::setRenderCallback(function (string $tpl, array $ctx) use (&$captured): string {
|
||||
$captured = $ctx;
|
||||
return '';
|
||||
});
|
||||
|
||||
$args = ['foo' => 'bar'];
|
||||
$this->override->beforeTemplatePart($templateName, '', '', $args);
|
||||
|
||||
// Clean up the buffer.
|
||||
$this->override->afterTemplatePart($templateName, '', '', $args);
|
||||
|
||||
$this->assertIsArray($captured);
|
||||
$this->assertSame('bar', $captured['foo']);
|
||||
}
|
||||
|
||||
public function testBeforeTemplatePartInjectsGlobalProduct(): void
|
||||
{
|
||||
$templateName = 'cart/cart.php';
|
||||
$captured = null;
|
||||
|
||||
$product = new \WC_Product(42);
|
||||
$GLOBALS['product'] = $product;
|
||||
|
||||
TwigService::setRenderCallback(function (string $tpl, array $ctx) use (&$captured): string {
|
||||
$captured = $ctx;
|
||||
return '';
|
||||
});
|
||||
|
||||
$this->override->beforeTemplatePart($templateName, '', '', []);
|
||||
$this->override->afterTemplatePart($templateName, '', '', []);
|
||||
|
||||
$this->assertSame($product, $captured['product']);
|
||||
|
||||
unset($GLOBALS['product']);
|
||||
}
|
||||
|
||||
public function testBeforeTemplatePartFallsBackOnRenderException(): void
|
||||
{
|
||||
$templateName = 'cart/cart.php';
|
||||
|
||||
TwigService::setRenderCallback(function (): string {
|
||||
throw new \RuntimeException('Twig error');
|
||||
});
|
||||
|
||||
$levelBefore = ob_get_level();
|
||||
|
||||
// Should not throw — falls back silently and lets PHP render.
|
||||
@$this->override->beforeTemplatePart($templateName, '', '', []);
|
||||
|
||||
// No buffer started on failure.
|
||||
$this->assertSame($levelBefore, ob_get_level());
|
||||
}
|
||||
}
|
||||
259
tests/Unit/Twig/WooCommerceExtensionTest.php
Normal file
259
tests/Unit/Twig/WooCommerceExtensionTest.php
Normal file
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WcBootstrap\Tests\Unit\Twig;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
use Twig\TwigFunction;
|
||||
use Twig\TwigFilter;
|
||||
use WcBootstrap\Twig\WooCommerceExtension;
|
||||
|
||||
class WooCommerceExtensionTest extends TestCase
|
||||
{
|
||||
private WooCommerceExtension $extension;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
$this->extension = new WooCommerceExtension();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── getFunctions / getFilters ───────────────────────────────
|
||||
|
||||
public function testGetFunctionsReturnsNonEmptyArray(): void
|
||||
{
|
||||
$functions = $this->extension->getFunctions();
|
||||
|
||||
$this->assertNotEmpty($functions);
|
||||
$this->assertContainsOnlyInstancesOf(TwigFunction::class, $functions);
|
||||
}
|
||||
|
||||
public function testGetFiltersReturnsNonEmptyArray(): void
|
||||
{
|
||||
$filters = $this->extension->getFilters();
|
||||
|
||||
$this->assertNotEmpty($filters);
|
||||
$this->assertContainsOnlyInstancesOf(TwigFilter::class, $filters);
|
||||
}
|
||||
|
||||
public function testGetFunctionsContainsExpectedNames(): void
|
||||
{
|
||||
$names = array_map(
|
||||
fn(TwigFunction $f) => $f->getName(),
|
||||
$this->extension->getFunctions()
|
||||
);
|
||||
|
||||
$expected = [
|
||||
'do_action', 'apply_filters', 'wp_nonce_field', 'fn',
|
||||
'wc_get_cart_url', 'wc_get_checkout_url', 'wc_get_template',
|
||||
'wc_print_notices', 'woocommerce_form_field',
|
||||
];
|
||||
|
||||
foreach ($expected as $name) {
|
||||
$this->assertContains($name, $names, "Missing Twig function: {$name}");
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetFiltersContainsExpectedNames(): void
|
||||
{
|
||||
$names = array_map(
|
||||
fn(TwigFilter $f) => $f->getName(),
|
||||
$this->extension->getFilters()
|
||||
);
|
||||
|
||||
$expected = [
|
||||
'esc_html', 'esc_attr', 'esc_url',
|
||||
'sanitize_title', 'wpautop', 'wp_kses_post',
|
||||
'wptexturize', 'do_shortcode',
|
||||
];
|
||||
|
||||
foreach ($expected as $name) {
|
||||
$this->assertContains($name, $names, "Missing Twig filter: {$name}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── callFunction() whitelist ────────────────────────────────
|
||||
|
||||
public function testCallFunctionWithAllowedFunctionSucceeds(): void
|
||||
{
|
||||
// Define a stub for an allowed function.
|
||||
Functions\when('_n')->alias(fn($single, $plural, $count) => $count === 1 ? $single : $plural);
|
||||
|
||||
$result = $this->extension->callFunction('_n', 'item', 'items', 1);
|
||||
|
||||
$this->assertSame('item', $result);
|
||||
}
|
||||
|
||||
public function testCallFunctionWithDisallowedFunctionThrows(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('is not allowed');
|
||||
|
||||
$this->extension->callFunction('exec', 'whoami');
|
||||
}
|
||||
|
||||
public function testCallFunctionWithNonExistentAllowedFunctionThrows(): void
|
||||
{
|
||||
// 'WC' is allowed but won't exist in the test environment.
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('does not exist');
|
||||
|
||||
$this->extension->callFunction('WC');
|
||||
}
|
||||
|
||||
// ── doAction() ──────────────────────────────────────────────
|
||||
|
||||
public function testDoActionCapturesOutput(): void
|
||||
{
|
||||
Functions\when('do_action')->alias(function (string $tag): void {
|
||||
echo "<div>{$tag}</div>";
|
||||
});
|
||||
|
||||
$result = $this->extension->doAction('woocommerce_before_cart');
|
||||
|
||||
$this->assertSame('<div>woocommerce_before_cart</div>', $result);
|
||||
}
|
||||
|
||||
public function testDoActionReturnsEmptyWhenNoOutput(): void
|
||||
{
|
||||
Functions\when('do_action')->justReturn(null);
|
||||
|
||||
$result = $this->extension->doAction('some_silent_hook');
|
||||
|
||||
$this->assertSame('', $result);
|
||||
}
|
||||
|
||||
// ── applyFilters() ──────────────────────────────────────────
|
||||
|
||||
public function testApplyFiltersDelegatesToWordPress(): void
|
||||
{
|
||||
Functions\when('apply_filters')->alias(function (string $tag, ...$args) {
|
||||
return strtoupper($args[0]);
|
||||
});
|
||||
|
||||
$result = $this->extension->applyFilters('the_title', 'hello');
|
||||
|
||||
$this->assertSame('HELLO', $result);
|
||||
}
|
||||
|
||||
// ── callUserFunc() ──────────────────────────────────────────
|
||||
|
||||
public function testCallUserFuncCapturesCallbackOutput(): void
|
||||
{
|
||||
$result = $this->extension->callUserFunc(function (): void {
|
||||
echo 'tab-content';
|
||||
});
|
||||
|
||||
$this->assertSame('tab-content', $result);
|
||||
}
|
||||
|
||||
public function testCallUserFuncPassesArguments(): void
|
||||
{
|
||||
$result = $this->extension->callUserFunc(function (string $key): void {
|
||||
echo "key={$key}";
|
||||
}, 'description');
|
||||
|
||||
$this->assertSame('key=description', $result);
|
||||
}
|
||||
|
||||
// ── setupProductData() ──────────────────────────────────────
|
||||
|
||||
public function testSetupProductDataSetsGlobalProduct(): void
|
||||
{
|
||||
$product = new \WC_Product(99);
|
||||
|
||||
Functions\when('get_post')->justReturn((object) ['ID' => 99]);
|
||||
Functions\when('setup_postdata')->justReturn(true);
|
||||
|
||||
$result = $this->extension->setupProductData($product);
|
||||
|
||||
$this->assertSame('', $result);
|
||||
$this->assertSame($product, $GLOBALS['product']);
|
||||
|
||||
unset($GLOBALS['product'], $GLOBALS['post']);
|
||||
}
|
||||
|
||||
public function testSetupProductDataSkipsPostdataWhenPostNotFound(): void
|
||||
{
|
||||
$product = new \WC_Product(1);
|
||||
|
||||
Functions\when('get_post')->justReturn(null);
|
||||
|
||||
$result = $this->extension->setupProductData($product);
|
||||
|
||||
$this->assertSame('', $result);
|
||||
$this->assertSame($product, $GLOBALS['product']);
|
||||
|
||||
unset($GLOBALS['product']);
|
||||
}
|
||||
|
||||
// ── Output-capture wrappers ─────────────────────────────────
|
||||
|
||||
public function testWcPrintNoticesCapturesOutput(): void
|
||||
{
|
||||
Functions\when('wc_print_notices')->alias(function (): void {
|
||||
echo '<div class="woocommerce-message">Notice</div>';
|
||||
});
|
||||
|
||||
$result = $this->extension->wcPrintNotices();
|
||||
|
||||
$this->assertStringContainsString('woocommerce-message', $result);
|
||||
}
|
||||
|
||||
public function testWcDisplayItemMetaReturnsString(): void
|
||||
{
|
||||
$item = new \stdClass();
|
||||
|
||||
Functions\when('wc_display_item_meta')->alias(function ($item, array $args): string {
|
||||
return $args['echo'] === false ? '<span>meta</span>' : '';
|
||||
});
|
||||
|
||||
$result = $this->extension->wcDisplayItemMeta($item, []);
|
||||
|
||||
$this->assertSame('<span>meta</span>', $result);
|
||||
}
|
||||
|
||||
public function testWcQueryStringFormFieldsReturnsHtml(): void
|
||||
{
|
||||
Functions\when('wc_query_string_form_fields')->alias(function ($values, array $exclude, string $key, bool $return): string {
|
||||
return $return ? '<input type="hidden">' : '';
|
||||
});
|
||||
|
||||
$result = $this->extension->wcQueryStringFormFields(null, [], '');
|
||||
|
||||
$this->assertSame('<input type="hidden">', $result);
|
||||
}
|
||||
|
||||
public function testWoocommerceFormFieldReturnsHtml(): void
|
||||
{
|
||||
Functions\when('woocommerce_form_field')->alias(function (string $key, array $args, $value): string {
|
||||
return $args['return'] === true ? "<p class=\"form-row\">{$key}</p>" : '';
|
||||
});
|
||||
|
||||
$result = $this->extension->woocommerceFormField('billing_first_name', [], null);
|
||||
|
||||
$this->assertStringContainsString('billing_first_name', $result);
|
||||
}
|
||||
|
||||
public function testWcGetTemplateCapturesOutput(): void
|
||||
{
|
||||
Functions\when('wc_get_template')->alias(function (string $name): void {
|
||||
echo "<div>{$name}</div>";
|
||||
});
|
||||
|
||||
$result = $this->extension->wcGetTemplate('order/order-details.php', []);
|
||||
|
||||
$this->assertSame('<div>order/order-details.php</div>', $result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user