File "EditModeLifecycleTest.php"

Full Path: /home/buyiwexj/public_html/wp-content/plugins/extendify/tests/Integration/QuickEdit/EditModeLifecycleTest.php
File size: 15.83 KB
MIME-type: text/x-php
Charset: utf-8

<?php

namespace Extendify\Tests\Integration\QuickEdit;

use Extendify\Config;
use Extendify\PartnerData;
use Extendify\QuickEdit\Frontend;
use WP_UnitTestCase;

/**
 * Characterizes Edit Mode lifecycle gates:
 *
 *  - Frontend::registerAdminBar + Frontend::enqueue both gate on
 *    current_user_can(Config::$requiredCapability) — matches the
 *    plugin-wide bootstrap gate (default 'manage_options'). Editors
 *    do NOT see the pill or get the JS bundle.
 *
 *  - The 'showQuickEdit' partner flag (or the 'quick-edit' preview
 *    opt-in) gates the inline-edit surfaces: the admin-bar toggle node
 *    only registers when enabled, and the inline payload carries
 *    'quickEditEnabled' + a defaultOn that is false while QE is off.
 *    The selector JS bundle still enqueues either way — Ask AI rides
 *    on it independently.
 */
class EditModeLifecycleTest extends WP_UnitTestCase
{
    public static function setUpBeforeClass(): void
    {
        parent::setUpBeforeClass();
        // The plugin bootstrap normally defines these inside an IIFE
        // that does not run under PHPUnit. Default to non-DEVMODE so
        // the enqueue path matches a production install.
        if (!defined('EXTENDIFY_DEVMODE')) {
            define('EXTENDIFY_DEVMODE', false);
        }
        if (!defined('EXTENDIFY_REQUIRED_CAPABILITY')) {
            define('EXTENDIFY_REQUIRED_CAPABILITY', 'manage_options');
        }
        if (!defined('EXTENDIFY_PATH')) {
            define('EXTENDIFY_PATH', dirname(__DIR__, 3) . '/');
        }
        if (!defined('EXTENDIFY_BASE_URL')) {
            define('EXTENDIFY_BASE_URL', 'http://example.org/wp-content/plugins/extendify-sdk/');
        }
        // Stub the asset manifest rather than reading the real built file.
        // public/build/ is gitignored and the PHPUnit CI job never runs the
        // webpack build, so a disk read left the manifest empty there and the
        // enqueue() assertions failed only in CI (passed locally off a stale
        // build). A self-keyed stub is enough — enqueue() file_exists-guards
        // the asset .php require. Mirrors Toolbar/FrontendTest::setManifestEntries.
        Config::$assetManifest = [
            'extendify-quick-edit.php' => 'extendify-quick-edit.php',
            'extendify-quick-edit.js'  => 'extendify-quick-edit.js',
            'extendify-quick-edit.css' => 'extendify-quick-edit.css',
        ];
    }

    public function tearDown(): void
    {
        remove_all_actions('admin_bar_menu');
        remove_all_actions('wp_enqueue_scripts');
        // Static PartnerData::$config leaks across tests/classes — reset the
        // flag this suite flips so a later test doesn't inherit it.
        $this->setPartnerSetting('showQuickEdit', false);
        Config::$launchCompleted = false;
        unset($GLOBALS['wp_customize']);
        parent::tearDown();
    }

    public function test_constructor_hooks_admin_bar_menu_at_priority_100()
    {
        new Frontend();

        $this->assertSame(100, has_action('admin_bar_menu', [
            $this->latestFrontendInstance(),
            'registerAdminBar',
        ]));
    }

    public function test_constructor_hooks_wp_enqueue_scripts()
    {
        $frontend = new Frontend();

        $this->assertNotFalse(has_action('wp_enqueue_scripts', [
            $frontend,
            'enqueue',
        ]));
    }

    public function test_register_admin_bar_skips_when_logged_out()
    {
        wp_set_current_user(0);
        $bar = $this->fakeAdminBar();

        (new Frontend())->registerAdminBar($bar);

        $this->assertSame([], $bar->nodes);
    }

    public function test_register_admin_bar_skips_in_admin_context()
    {
        $this->loginAs('administrator');
        set_current_screen('edit-post');
        $bar = $this->fakeAdminBar();

        (new Frontend())->registerAdminBar($bar);

        $this->assertSame([], $bar->nodes);
        set_current_screen('front');
    }

    public function test_register_admin_bar_for_administrator_on_frontend()
    {
        $this->loginAs('administrator');
        $this->setPartnerSetting('showQuickEdit', true);
        $bar = $this->fakeAdminBar();

        (new Frontend())->registerAdminBar($bar);

        $this->assertCount(1, $bar->nodes);
        $this->assertSame('extendify-quick-edit-toggle', $bar->nodes[0]['id']);
        $this->assertStringContainsString('Quick Edit', $bar->nodes[0]['title']);
        $this->assertSame('switch', $bar->nodes[0]['meta']['role']);
    }

    public function test_register_admin_bar_skips_for_editor_without_manage_options()
    {
        // Locked down: the pill follows Config::$requiredCapability
        // (default manage_options). Editor role has edit_posts but not
        // manage_options, so the pill must not register.
        $this->loginAs('editor');
        $bar = $this->fakeAdminBar();

        (new Frontend())->registerAdminBar($bar);

        $this->assertSame([], $bar->nodes);
    }

    public function test_register_admin_bar_skips_for_subscriber()
    {
        $this->loginAs('subscriber');
        $bar = $this->fakeAdminBar();

        (new Frontend())->registerAdminBar($bar);

        $this->assertSame([], $bar->nodes);
    }

    public function test_enqueue_skips_when_user_lacks_required_capability()
    {
        // Editor (edit_posts but not manage_options) is the meaningful
        // boundary post-lockdown; subscriber would skip anyway.
        $this->loginAs('editor');

        (new Frontend())->enqueue();

        $this->assertFalse(wp_script_is('extendify-quick-edit', 'enqueued'));
        $this->assertFalse(wp_script_is('extendify-quick-edit', 'registered'));
    }

    public function test_enqueue_skips_when_logged_out()
    {
        wp_set_current_user(0);

        (new Frontend())->enqueue();

        $this->assertFalse(wp_script_is('extendify-quick-edit', 'enqueued'));
    }

    public function test_enqueue_skips_in_admin_context()
    {
        $this->loginAs('administrator');
        set_current_screen('edit-post');

        (new Frontend())->enqueue();

        $this->assertFalse(wp_script_is('extendify-quick-edit', 'enqueued'));
        set_current_screen('front');
    }

    public function test_enqueue_skips_in_customize_preview()
    {
        // The Customizer preview iframe renders the front end (is_admin()
        // false) for an admin user, so only is_customize_preview() tells QE
        // apart from a normal front-end request — it must not enqueue there.
        $this->loginAs('administrator');
        require_once ABSPATH . 'wp-includes/class-wp-customize-manager.php';
        $GLOBALS['wp_customize'] = new \WP_Customize_Manager();
        $GLOBALS['wp_customize']->start_previewing_theme();

        // Clear our handle so the assertion reflects THIS enqueue() call, not a
        // handle a prior test left enqueued (WP_Scripts persists across tests).
        wp_dequeue_script('extendify-quick-edit');

        (new Frontend())->enqueue();

        $this->assertFalse(wp_script_is('extendify-quick-edit', 'enqueued'));
    }

    public function test_enqueue_registers_inline_extQuickEditData_for_administrator()
    {
        $this->loginAs('administrator');

        (new Frontend())->enqueue();

        $this->assertTrue(wp_script_is('extendify-quick-edit', 'enqueued'));

        $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
        $this->assertStringContainsString('window.extQuickEditData', $inline);
        $this->assertStringContainsString('"restRoot"', $inline);
        $this->assertStringContainsString('"schemas"', $inline);
        $this->assertStringContainsString('"defaultOn"', $inline);
    }

    public function test_enqueue_surfaces_translated_context_default_false()
    {
        // The payload always carries translatedContext so the client can gate
        // text edits on a translated render. With no multilingual plugin active
        // it reports the default-language shape. Detection per plugin is covered
        // in QuickEdit/Services/TranslatedContextTest.
        $this->loginAs('administrator');

        (new Frontend())->enqueue();

        $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
        $payload = $this->extractInlineQuickEditData($inline);
        $this->assertArrayHasKey('translatedContext', $payload);
        $this->assertFalse($payload['translatedContext']['isTranslated']);
        $this->assertNull($payload['translatedContext']['plugin']);
    }

    public function test_enqueue_inline_data_surfaces_currency_symbol_when_wc_is_loaded()
    {
        // The bootstrap requires WooCommerce under WP_CONTENT_DIR.
        // If it didn't load, the producer side of this contract can't fire
        // and the JS modal falls back to '$' even on non-USD stores.
        if (!function_exists('get_woocommerce_currency_symbol')) {
            $this->markTestSkipped('WooCommerce required for currency symbol surfacing.');
        }

        update_option('woocommerce_currency', 'EUR');

        $this->loginAs('administrator');
        (new Frontend())->enqueue();

        $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
        $this->assertStringContainsString('"currencySymbol"', $inline);
        $payload = $this->extractInlineQuickEditData($inline);
        $this->assertSame('€', $payload['context']['currencySymbol']);
    }

    /**
     * @return array<string,mixed>
     */
    private function extractInlineQuickEditData(string $inline): array
    {
        // The 'before' inline data accumulates one
        // `window.extQuickEditData = {...};` block per enqueue() across the
        // suite (WP_Scripts isn't reset between tests). The current test's
        // block is the last one appended, so anchor on the final prefix and
        // brace-walk (string-aware) to its matching close — a last-`}` scan
        // would instead span every block and yield invalid JSON.
        $prefix = 'window.extQuickEditData = ';
        $start = strrpos($inline, $prefix);
        $this->assertNotFalse($start, 'inline payload missing window.extQuickEditData');
        $string = substr($inline, $start + strlen($prefix));
        $json = '';
        $depth = 0;
        $inString = false;
        $escaped = false;
        for ($i = 0, $length = strlen($string); $i < $length; $i++) {
            $char = $string[$i];
            $json .= $char;
            if ($inString) {
                if ($escaped) {
                    $escaped = false;
                } elseif ($char === '\\') {
                    $escaped = true;
                } elseif ($char === '"') {
                    $inString = false;
                }
                continue;
            }
            if ($char === '"') {
                $inString = true;
            } elseif ($char === '{') {
                $depth++;
            } elseif ($char === '}') {
                $depth--;
                if ($depth === 0) {
                    break;
                }
            }
        }

        $decoded = json_decode($json, true);
        $this->assertIsArray($decoded);
        return $decoded;
    }

    public function test_enqueue_default_on_is_false_pre_launch()
    {
        // Pre-launch + non-DEVMODE: defaultOn is false. The user opts in
        // by toggling the admin-bar pill; persist hydrates that choice
        // on later visits.
        Config::$launchCompleted = false;
        $this->loginAs('administrator');

        (new Frontend())->enqueue();

        $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
        $payload = $this->extractInlineQuickEditData($inline);
        $this->assertFalse($payload['defaultOn']);
    }

    public function test_enqueue_default_on_is_true_post_launch()
    {
        // Post-launch installs with QE enabled land with edit mode engaged
        // on first visit. The persisted toggle (zustand/persist) overrides
        // this on later visits — see tests/unit/QuickEdit/state/edit-mode.test.js.
        Config::$launchCompleted = true;
        $this->loginAs('administrator');
        $this->setPartnerSetting('showQuickEdit', true);

        (new Frontend())->enqueue();

        $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
        $payload = $this->extractInlineQuickEditData($inline);
        $this->assertTrue($payload['defaultOn']);

        Config::$launchCompleted = false;
    }

    public function test_show_quick_edit_flag_gates_admin_bar_pill()
    {
        $this->loginAs('administrator');

        // Flag off (default) → no toggle node.
        $this->setPartnerSetting('showQuickEdit', false);
        $barOff = $this->fakeAdminBar();
        (new Frontend())->registerAdminBar($barOff);
        $this->assertSame([], $barOff->nodes);

        // Flag on → the Edit-mode toggle node registers.
        $this->setPartnerSetting('showQuickEdit', true);
        $barOn = $this->fakeAdminBar();
        (new Frontend())->registerAdminBar($barOn);
        $this->assertCount(1, $barOn->nodes);
        $this->assertSame('extendify-quick-edit-toggle', $barOn->nodes[0]['id']);
    }

    public function test_enqueue_still_loads_selector_bundle_when_quick_edit_off()
    {
        // showQuickEdit gates the inline surfaces, never the bundle load —
        // the selector ships for Ask AI regardless. quickEditEnabled rides
        // in the payload so the JS can gate the pills.
        $this->loginAs('administrator');
        $this->setPartnerSetting('showQuickEdit', false);

        (new Frontend())->enqueue();

        $this->assertTrue(wp_script_is('extendify-quick-edit', 'enqueued'));
        $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
        $this->assertStringContainsString('"quickEditEnabled":false', $inline);
    }

    public function test_enqueue_surfaces_quick_edit_enabled_true_when_flag_on()
    {
        $this->loginAs('administrator');
        $this->setPartnerSetting('showQuickEdit', true);

        (new Frontend())->enqueue();

        $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
        $this->assertStringContainsString('"quickEditEnabled":true', $inline);
    }

    public function test_enqueue_default_on_false_when_quick_edit_off_even_post_launch()
    {
        // The post-Launch auto-on is suppressed while QE is off — the agent's
        // Select button becomes the entry point instead. Asserted via the raw
        // inline JSON to sidestep the env-drifted extractInlineQuickEditData
        // helper.
        Config::$launchCompleted = true;
        $this->loginAs('administrator');
        $this->setPartnerSetting('showQuickEdit', false);

        (new Frontend())->enqueue();

        $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
        $this->assertStringContainsString('"defaultOn":false', $inline);
    }

    private function loginAs(string $role): int
    {
        $user = self::factory()->user->create(['role' => $role]);
        wp_set_current_user($user);
        return $user;
    }

    private function fakeAdminBar(): object
    {
        return new class () {
            public array $nodes = [];
            public function add_node($node): void
            {
                $this->nodes[] = $node;
            }
        };
    }

    private function setPartnerSetting(string $key, $value): void
    {
        $prop = new \ReflectionProperty(PartnerData::class, 'config');
        $prop->setAccessible(true);
        $config = $prop->getValue();
        $config[$key] = $value;
        $prop->setValue(null, $config);
    }

    private function latestFrontendInstance(): Frontend
    {
        global $wp_filter;
        foreach ($wp_filter['admin_bar_menu']->callbacks[100] ?? [] as $cb) {
            if (is_array($cb['function']) && $cb['function'][0] instanceof Frontend) {
                return $cb['function'][0];
            }
        }
        $this->fail('No Frontend instance found on admin_bar_menu.');
    }
}