test,ci: 建立 phpunit 基建 + 移植 WC 測試 + CI workflow
- composer.json / phpunit.xml / tests/bootstrap.php(比照 HP AddOn, 直接 require 核心 plugin 的 unit bootstrap) - tests/unit/WooCommerceIntegrationTest.php(自 A 移植,10 tests) - .gitea/workflows/test.yml:lint + unit(unit job 會一併 checkout 核心 plugin, 因為測試 bootstrap 依賴它) 10 tests / 33 assertions GREEN Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
/**
|
||||
* PHPUnit bootstrap for the WooCommerce AddOn.
|
||||
*
|
||||
* Reuses the core plugin's unit bootstrap (WP function stubs, $wpdb stub,
|
||||
* constants, WPDO_* aliases) and loads this AddOn's classes on top.
|
||||
*
|
||||
* @package TMDO_WOOCOMMERCE
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$tmdo_core_bootstrap = dirname( __DIR__, 2 ) . '/2meet-data-optimizer/tests/bootstrap.php';
|
||||
if ( ! file_exists( $tmdo_core_bootstrap ) ) {
|
||||
throw new RuntimeException(
|
||||
'WooCommerce AddOn tests require the core plugin at ' . dirname( __DIR__, 2 ) . '/2meet-data-optimizer'
|
||||
);
|
||||
}
|
||||
require_once $tmdo_core_bootstrap;
|
||||
|
||||
define( 'TMDO_WOOCOMMERCE_PATH', dirname( __DIR__ ) . '/' );
|
||||
define( 'TMDO_WOOCOMMERCE_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-woocommerce-addon/' );
|
||||
define( 'TMDO_WOOCOMMERCE_FILE', dirname( __DIR__ ) . '/2meet-data-optimizer-woocommerce-addon.php' );
|
||||
define( 'TMDO_WOOCOMMERCE_VERSION', '0.1.0' );
|
||||
|
||||
foreach ( array(
|
||||
'includes/class-tmdo-woocommerce.php',
|
||||
'includes/class-tmdo-wc-orders-interceptor.php',
|
||||
'includes/class-tmdo-wc-term-count-filter.php',
|
||||
'admin/class-tmdo-admin-wc.php',
|
||||
) as $tmdo_wc_file ) {
|
||||
$tmdo_wc_path = TMDO_WOOCOMMERCE_PATH . $tmdo_wc_file;
|
||||
if ( file_exists( $tmdo_wc_path ) ) {
|
||||
require_once $tmdo_wc_path;
|
||||
}
|
||||
}
|
||||
unset( $tmdo_wc_file, $tmdo_wc_path, $tmdo_core_bootstrap );
|
||||
|
||||
// Mirrors includes/back-compat-aliases.php, which runs on plugins_loaded:7 and
|
||||
// therefore never fires under PHPUnit.
|
||||
foreach ( get_declared_classes() as $tmdo_declared ) {
|
||||
if ( str_starts_with( $tmdo_declared, 'TMDO_' ) ) {
|
||||
$tmdo_alias = 'WPDO_' . substr( $tmdo_declared, 5 );
|
||||
if ( ! class_exists( $tmdo_alias, false ) ) {
|
||||
class_alias( $tmdo_declared, $tmdo_alias );
|
||||
}
|
||||
}
|
||||
}
|
||||
unset( $tmdo_declared, $tmdo_alias );
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for WPDO_WooCommerce + WPDO_WC_Orders_Interceptor + new CLI methods.
|
||||
*
|
||||
* v2.1.2 — covers the test-coverage gaps surfaced by the production audit
|
||||
* (PRODUCTION_AUDIT_2026-04-26.md, test-automator section, 5 prioritized tests).
|
||||
*/
|
||||
class WooCommerceIntegrationTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
// Reset request-level state between tests.
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
$GLOBALS['_wp_post_types'] = array();
|
||||
$GLOBALS['_wp_current_user_can'] = array();
|
||||
$GLOBALS['_wpdo_inserts'] = array();
|
||||
$GLOBALS['_wpdo_upserts'] = array();
|
||||
|
||||
// Ensure $wpdb has a $queries property — other tests may have swapped
|
||||
// $wpdb to a mock that doesn't define this property.
|
||||
global $wpdb;
|
||||
if ( ! property_exists( $wpdb, 'queries' ) ) {
|
||||
$wpdb = $GLOBALS['_wpdo_orig_wpdb'] ?? new class {
|
||||
public string $prefix = 'wp_';
|
||||
public array $queries = [];
|
||||
public function prepare( string $sql, ...$args ): string { return $sql; }
|
||||
public function get_var( string $sql ): ?string { return null; }
|
||||
public function get_row( string $sql, $output = OBJECT ) { return null; }
|
||||
public function get_results( string $sql, $output = OBJECT ): array { return []; }
|
||||
public function get_col( string $sql, int $x = 0 ): array { return []; }
|
||||
public function query( string $sql ) { $this->queries[] = $sql; return 1; }
|
||||
public function insert( string $t, array $d, $f = null ): int { $this->queries[] = "INSERT $t"; return 1; }
|
||||
public function update( string $t, array $d, array $w, $df = null, $wf = null ): int { return 1; }
|
||||
public function delete( string $t, array $w, $wf = null ): int { return 1; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test #5 — WPDO_WooCommerce::is_active() returns false without WC ──
|
||||
|
||||
public function test_is_active_returns_false_when_wc_not_installed(): void {
|
||||
// In the test bootstrap WooCommerce class is NOT defined, WC_VERSION is NOT set,
|
||||
// and the WC plugin file does not exist at the fake plugin dir.
|
||||
// Conditions match a non-WC environment.
|
||||
$this->assertFalse( class_exists( 'WooCommerce', false ) );
|
||||
$this->assertFalse( defined( 'WC_VERSION' ) );
|
||||
|
||||
// All 3 detection paths should be false.
|
||||
// is_active() is loose: returns true if ANY path is true. With all three false → false.
|
||||
// We can't easily test without running the actual method, but we can verify
|
||||
// each detection branch returns falsy:
|
||||
$class_check = class_exists( 'WooCommerce' );
|
||||
$const_check = defined( 'WC_VERSION' ) && WC_VERSION;
|
||||
// In test env WP_PLUGIN_DIR may not be defined; that's a 4th sign of "no WP".
|
||||
$file_check = defined( 'WP_PLUGIN_DIR' )
|
||||
? file_exists( WP_PLUGIN_DIR . '/woocommerce/woocommerce.php' )
|
||||
: false;
|
||||
|
||||
$this->assertFalse( $class_check );
|
||||
$this->assertFalse( (bool) $const_check );
|
||||
$this->assertFalse( $file_check );
|
||||
}
|
||||
|
||||
// ── Test #1 — WC_Orders_Interceptor skips untracked statuses ──
|
||||
|
||||
public function test_action_order_status_changed_skips_untracked_status(): void {
|
||||
$ic = new WPDO_WC_Orders_Interceptor();
|
||||
|
||||
// Stub feature flags so is_active() returns true (forces sync_commission to be reached).
|
||||
WPDO_Feature_Flags::set( 'wc_orders', 'cutover' );
|
||||
|
||||
// Snapshot $wpdb query count BEFORE.
|
||||
global $wpdb;
|
||||
$before_query_count = count( $wpdb->queries );
|
||||
|
||||
// Untracked status — should early-return before sync_commission runs.
|
||||
// If it reached sync_commission, the wc_get_order() call would either throw
|
||||
// (function undefined in tests) or — if defined — eventually call $wpdb->query().
|
||||
// Either way, the early-return path means $wpdb->queries does NOT grow.
|
||||
$ic->action_order_status_changed( 999, 'pending', 'on-hold' );
|
||||
|
||||
$after_query_count = count( $wpdb->queries );
|
||||
$this->assertSame(
|
||||
$before_query_count,
|
||||
$after_query_count,
|
||||
'Untracked status must NOT trigger any $wpdb queries (no sync_commission)'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_action_order_status_changed_skips_when_inactive(): void {
|
||||
$ic = new WPDO_WC_Orders_Interceptor();
|
||||
// Module not in WRITE_ACTIVE_STATES → is_active() false.
|
||||
WPDO_Feature_Flags::set( 'wc_orders', 'idle' );
|
||||
|
||||
global $wpdb;
|
||||
$before_query_count = count( $wpdb->queries );
|
||||
|
||||
$ic->action_order_status_changed( 999, 'pending', 'completed' );
|
||||
|
||||
$after_query_count = count( $wpdb->queries );
|
||||
$this->assertSame(
|
||||
$before_query_count,
|
||||
$after_query_count,
|
||||
'Inactive module must NOT trigger any $wpdb queries'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test #2 — vendor_summary returns zeroed struct on null DB result ──
|
||||
|
||||
public function test_vendor_summary_returns_zeroed_struct_for_unknown_vendor(): void {
|
||||
// Bootstrap $wpdb->get_row returns null by default.
|
||||
$summary = WPDO_WC_Orders_Interceptor::vendor_summary( 99999 );
|
||||
|
||||
$this->assertIsArray( $summary );
|
||||
$this->assertArrayHasKey( 'total_subtotal', $summary );
|
||||
$this->assertArrayHasKey( 'total_commission', $summary );
|
||||
$this->assertArrayHasKey( 'total_payout', $summary );
|
||||
$this->assertArrayHasKey( 'order_count', $summary );
|
||||
|
||||
// Types matter for template rendering (number_format crashes on null).
|
||||
$this->assertSame( 0.0, $summary['total_subtotal'] );
|
||||
$this->assertSame( 0.0, $summary['total_commission'] );
|
||||
$this->assertSame( 0.0, $summary['total_payout'] );
|
||||
$this->assertSame( 0, $summary['order_count'] );
|
||||
}
|
||||
|
||||
public function test_vendor_summary_filters_by_status(): void {
|
||||
// Same null DB scenario — we just verify status param doesn't throw.
|
||||
$summary = WPDO_WC_Orders_Interceptor::vendor_summary( 1, 'completed' );
|
||||
$this->assertSame( 0, $summary['order_count'] );
|
||||
}
|
||||
|
||||
// ── Test #3 — WPDO_Core::render_health_alert_notice suppresses on empty ──
|
||||
|
||||
public function test_render_health_alert_notice_suppresses_when_option_empty(): void {
|
||||
// $GLOBALS['_wp_options']['wpdo_health_alert'] is unset → get_option returns ''.
|
||||
$core = new WPDO_Core();
|
||||
|
||||
ob_start();
|
||||
$core->render_health_alert_notice();
|
||||
$output = ob_get_clean();
|
||||
|
||||
$this->assertSame( '', $output, 'No HTML when no alert option' );
|
||||
}
|
||||
|
||||
public function test_render_health_alert_notice_suppresses_for_unprivileged(): void {
|
||||
$GLOBALS['_wp_options']['wpdo_health_alert'] = 'DB grew 50%';
|
||||
$GLOBALS['_wp_current_user_can']['manage_options'] = false;
|
||||
|
||||
$core = new WPDO_Core();
|
||||
ob_start();
|
||||
$core->render_health_alert_notice();
|
||||
$output = ob_get_clean();
|
||||
|
||||
$this->assertSame( '', $output, 'No HTML for users without manage_options' );
|
||||
}
|
||||
|
||||
// ── Test #4 — CLI render_stub generates valid PHP ──
|
||||
|
||||
public function test_render_stub_generates_valid_php(): void {
|
||||
$cli = new WPDO_CLI();
|
||||
$ref = new ReflectionMethod( 'WPDO_CLI', 'render_stub' );
|
||||
$ref->setAccessible( true );
|
||||
|
||||
$stub = $ref->invoke( $cli, '2meet-bookings', 'bk', 'TMEETIC_Bookings_WPDO' );
|
||||
|
||||
$this->assertStringContainsString( '<?php', $stub );
|
||||
$this->assertStringContainsString( "if ( ! defined( 'ABSPATH' ) )", $stub );
|
||||
$this->assertStringContainsString( 'class TMEETIC_Bookings_WPDO', $stub );
|
||||
$this->assertStringContainsString( "'2meet-bookings'", $stub );
|
||||
$this->assertStringContainsString( "'bk_example_one'", $stub );
|
||||
$this->assertStringContainsString( "'bk_example_two'", $stub );
|
||||
|
||||
// Verify it parses as valid PHP.
|
||||
$tmp = tempnam( sys_get_temp_dir(), 'wpdo_stub_' ) . '.php';
|
||||
file_put_contents( $tmp, $stub );
|
||||
exec( 'php -l ' . escapeshellarg( $tmp ) . ' 2>&1', $out, $rc );
|
||||
unlink( $tmp );
|
||||
|
||||
$this->assertSame( 0, $rc, "Generated stub is not valid PHP:\n" . implode( "\n", $out ) );
|
||||
}
|
||||
|
||||
public function test_render_stub_with_different_slug_changes_class_name(): void {
|
||||
$cli = new WPDO_CLI();
|
||||
$ref = new ReflectionMethod( 'WPDO_CLI', 'render_stub' );
|
||||
$ref->setAccessible( true );
|
||||
|
||||
$stub_a = $ref->invoke( $cli, 'plugin-a', 'pa', 'Plugin_A_WPDO' );
|
||||
$stub_b = $ref->invoke( $cli, 'plugin-b', 'pb', 'Plugin_B_WPDO' );
|
||||
|
||||
$this->assertStringContainsString( 'class Plugin_A_WPDO', $stub_a );
|
||||
$this->assertStringContainsString( 'class Plugin_B_WPDO', $stub_b );
|
||||
$this->assertStringNotContainsString( 'Plugin_A_WPDO', $stub_b );
|
||||
}
|
||||
|
||||
// ── Bonus: H4 fix verification — Schema_Registry post_type/entity_type normalization ──
|
||||
|
||||
public function test_schema_registry_normalizes_entity_type_to_post_type(): void {
|
||||
$registry = WPDO_Schema_Registry::instance();
|
||||
|
||||
// Snapshot current state to restore later (registry is a singleton).
|
||||
$ref = new ReflectionClass( $registry );
|
||||
$pp = $ref->getProperty( 'fields' );
|
||||
$pp->setAccessible( true );
|
||||
$original = $pp->getValue( $registry );
|
||||
|
||||
try {
|
||||
// Simulate a partner like 2meet-liff: entity_type='user', no post_type.
|
||||
$registry->register( 'test-provider', array(
|
||||
'entity_type' => 'user',
|
||||
'meta_key' => '_test_user_meta',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||
) );
|
||||
|
||||
// Should be retrievable under post_type='user' (normalized).
|
||||
$field = $registry->get_field( 'user', '_test_user_meta' );
|
||||
$this->assertNotNull( $field, 'Field should be findable under normalized post_type=user' );
|
||||
$this->assertSame( 'user', $field['post_type'] );
|
||||
$this->assertSame( 'user', $field['entity_type'] );
|
||||
|
||||
// And NOT under empty-string post_type.
|
||||
$bogus = $registry->get_field( '', '_test_user_meta' );
|
||||
$this->assertNull( $bogus, 'Field should NOT be findable under empty-string post_type' );
|
||||
} finally {
|
||||
// Restore.
|
||||
$pp->setValue( $registry, $original );
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user