File "WPNavigationControllerTest.php"

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

<?php

namespace Extendify\Tests\Integration\QuickEdit\Controllers;

use Extendify\QuickEdit\Controllers\WPNavigationController;
use Extendify\QuickEdit\Schemas\Registry;
use ReflectionClass;
use WP_UnitTestCase;

class WPNavigationControllerTest extends WP_UnitTestCase
{
    public function setUp(): void
    {
        parent::setUp();
        $this->resetRegistry();
        Registry::init();
        $this->loginAsAdmin();
    }

    public function tearDown(): void
    {
        $this->resetRegistry();
        parent::tearDown();
    }

    public function test_permission_callback_requires_admin_capability()
    {
        wp_set_current_user(0);
        $this->assertFalse(WPNavigationController::permissionCallback());

        $editor = self::factory()->user->create(['role' => 'editor']);
        wp_set_current_user($editor);
        $this->assertFalse(WPNavigationController::permissionCallback());

        $admin = self::factory()->user->create(['role' => 'administrator']);
        wp_set_current_user($admin);
        $this->assertTrue(WPNavigationController::permissionCallback());
    }

    public function test_missing_required_fields_returns_400()
    {
        $res = WPNavigationController::handle($this->jsonRequest([]));
        $this->assertSame(400, $res->get_status());
        $this->assertStringContainsString('required', $res->get_data()['error']);
    }

    public function test_unsupported_blockType_returns_400()
    {
        $navId = $this->createNav('<!-- wp:navigation-link {"label":"Home","url":"/"} /-->');

        $res = WPNavigationController::handle($this->jsonRequest([
            'navPostId' => $navId,
            'itemIndex' => 0,
            'blockType' => 'core/paragraph',
            'patches'   => [['fieldKey' => 'label', 'value' => 'X']],
        ]));

        $this->assertSame(400, $res->get_status());
        $this->assertSame('unsupported blockType for wp-navigation save', $res->get_data()['error']);
    }

    public function test_non_wp_navigation_post_returns_404()
    {
        $postId = self::factory()->post->create();

        $res = WPNavigationController::handle($this->jsonRequest([
            'navPostId' => $postId,
            'itemIndex' => 0,
            'blockType' => 'core/navigation-link',
            'patches'   => [['fieldKey' => 'label', 'value' => 'X']],
        ]));

        $this->assertSame(404, $res->get_status());
        $this->assertSame('wp_navigation post not found', $res->get_data()['error']);
    }

    public function test_subscriber_user_forbidden()
    {
        $navId = $this->createNav('<!-- wp:navigation-link {"label":"Home","url":"/"} /-->');
        $sub = self::factory()->user->create(['role' => 'subscriber']);
        wp_set_current_user($sub);

        $res = WPNavigationController::handle($this->jsonRequest([
            'navPostId' => $navId,
            'itemIndex' => 0,
            'blockType' => 'core/navigation-link',
            'patches'   => [['fieldKey' => 'label', 'value' => 'X']],
        ]));

        $this->assertSame(403, $res->get_status());
    }

    public function test_itemIndex_past_end_returns_404()
    {
        $navId = $this->createNav('<!-- wp:navigation-link {"label":"Home","url":"/"} /-->');

        $res = WPNavigationController::handle($this->jsonRequest([
            'navPostId' => $navId,
            'itemIndex' => 5,
            'blockType' => 'core/navigation-link',
            'patches'   => [['fieldKey' => 'label', 'value' => 'X']],
        ]));

        $this->assertSame(404, $res->get_status());
        $this->assertSame('nav item not found at index', $res->get_data()['error']);
        $this->assertSame(5, $res->get_data()['itemIndex']);
    }

    public function test_blockType_mismatch_returns_409()
    {
        $navId = $this->createNav('<!-- wp:navigation-link {"label":"Home","url":"/"} /-->');

        $res = WPNavigationController::handle($this->jsonRequest([
            'navPostId' => $navId,
            'itemIndex' => 0,
            'blockType' => 'core/navigation-submenu',
            'patches'   => [['fieldKey' => 'label', 'value' => 'X']],
        ]));

        $this->assertSame(409, $res->get_status());
        $this->assertSame('core/navigation-submenu', $res->get_data()['expected']);
        $this->assertSame('core/navigation-link', $res->get_data()['actual']);
    }

    public function test_updates_label_via_index_0()
    {
        $navId = $this->createNav('<!-- wp:navigation-link {"label":"Home","url":"/"} /-->');

        $res = WPNavigationController::handle($this->jsonRequest([
            'navPostId' => $navId,
            'itemIndex' => 0,
            'blockType' => 'core/navigation-link',
            'patches'   => [['fieldKey' => 'label', 'value' => 'Homepage']],
        ]));

        $this->assertSame(200, $res->get_status());
        $this->assertTrue($res->get_data()['ok']);

        $blocks = parse_blocks(get_post($navId)->post_content);
        $this->assertSame('Homepage', $blocks[0]['attrs']['label']);
    }

    public function test_updates_url_via_index()
    {
        $navId = $this->createNav('<!-- wp:navigation-link {"label":"Home","url":"/"} /-->');

        WPNavigationController::handle($this->jsonRequest([
            'navPostId' => $navId,
            'itemIndex' => 0,
            'blockType' => 'core/navigation-link',
            'patches'   => [['fieldKey' => 'url', 'value' => 'https://example.com/about']],
        ]));

        $blocks = parse_blocks(get_post($navId)->post_content);
        $this->assertSame('https://example.com/about', $blocks[0]['attrs']['url']);
    }

    public function test_index_counts_only_nav_link_and_nav_submenu()
    {
        // Three nav items. Submenu wrapping nested links shouldn't double-count.
        $content = '<!-- wp:navigation-link {"label":"One","url":"/1"} /-->'
                 . '<!-- wp:navigation-submenu {"label":"Two","url":"/2"} -->'
                 .   '<!-- wp:navigation-link {"label":"Two-A","url":"/2a"} /-->'
                 . '<!-- /wp:navigation-submenu -->'
                 . '<!-- wp:navigation-link {"label":"Three","url":"/3"} /-->';
        $navId = $this->createNav($content);

        // index 0 = One, 1 = Two (submenu), 2 = Two-A (nested), 3 = Three.
        WPNavigationController::handle($this->jsonRequest([
            'navPostId' => $navId,
            'itemIndex' => 3,
            'blockType' => 'core/navigation-link',
            'patches'   => [['fieldKey' => 'label', 'value' => 'Last']],
        ]));

        $blocks = parse_blocks(get_post($navId)->post_content);
        $this->assertSame('Last', $blocks[2]['attrs']['label']);
        // Earlier items untouched.
        $this->assertSame('One', $blocks[0]['attrs']['label']);
        $this->assertSame('Two', $blocks[1]['attrs']['label']);
        $this->assertSame('Two-A', $blocks[1]['innerBlocks'][0]['attrs']['label']);
    }

    public function test_save_to_index_3_after_deleting_item_at_index_1_targets_a_different_item()
    {
        // Characterize the index semantics: indices are positional. If items
        // are reordered/deleted between client read and save, the index will
        // address a different item — the controller doesn't reconcile.
        $base = '<!-- wp:navigation-link {"label":"A","url":"/a"} /-->'
              . '<!-- wp:navigation-link {"label":"B","url":"/b"} /-->'
              . '<!-- wp:navigation-link {"label":"C","url":"/c"} /-->';
        $navId = $this->createNav($base);

        // Delete B (originally at index 1) externally.
        $shortened = '<!-- wp:navigation-link {"label":"A","url":"/a"} /-->'
                   . '<!-- wp:navigation-link {"label":"C","url":"/c"} /-->';
        wp_update_post([
            'ID'           => $navId,
            'post_content' => wp_slash($shortened),
        ]);

        // Client thinks "edit C at index 2"; after deletion, index 2 doesn't exist.
        $res = WPNavigationController::handle($this->jsonRequest([
            'navPostId' => $navId,
            'itemIndex' => 2,
            'blockType' => 'core/navigation-link',
            'patches'   => [['fieldKey' => 'label', 'value' => 'C-renamed']],
        ]));

        $this->assertSame(404, $res->get_status());
    }

    // Submenu/ordering divergence can resolve a nav-item index to the
    // wrong same-type sibling. The fingerprint of the clicked item's label is
    // the safety net: a mismatch refuses instead of editing the wrong link.
    public function test_fingerprint_mismatch_returns_409_without_writing()
    {
        $content = '<!-- wp:navigation-link {"label":"A","url":"/a"} /-->'
                 . '<!-- wp:navigation-link {"label":"B","url":"/b"} /-->';
        $navId = $this->createNav($content);

        // index 1 counts to "B"; client meant "A".
        $res = WPNavigationController::handle($this->jsonRequest([
            'navPostId'   => $navId,
            'itemIndex'   => 1,
            'blockType'   => 'core/navigation-link',
            'fingerprint' => ['text' => 'A'],
            'patches'     => [['fieldKey' => 'label', 'value' => 'corrupted']],
        ]));

        $this->assertSame(409, $res->get_status());
        $this->assertSame('block fingerprint mismatch', $res->get_data()['error']);
        $blocks = parse_blocks(get_post($navId)->post_content);
        $this->assertSame('B', $blocks[1]['attrs']['label']);
    }

    public function test_matching_fingerprint_allows_save()
    {
        $content = '<!-- wp:navigation-link {"label":"A","url":"/a"} /-->'
                 . '<!-- wp:navigation-link {"label":"B","url":"/b"} /-->';
        $navId = $this->createNav($content);

        $res = WPNavigationController::handle($this->jsonRequest([
            'navPostId'   => $navId,
            'itemIndex'   => 1,
            'blockType'   => 'core/navigation-link',
            'fingerprint' => ['text' => 'B'],
            'patches'     => [['fieldKey' => 'label', 'value' => 'Beta']],
        ]));

        $this->assertSame(200, $res->get_status());
        $blocks = parse_blocks(get_post($navId)->post_content);
        $this->assertSame('Beta', $blocks[1]['attrs']['label']);
    }

    public function test_init_hooks_registerRoutes_into_rest_api_init()
    {
        remove_all_filters('rest_api_init');
        WPNavigationController::init();

        $this->assertNotFalse(
            has_action('rest_api_init', [WPNavigationController::class, 'registerRoutes'])
        );
    }

    private function createNav(string $content): int
    {
        return self::factory()->post->create([
            'post_type'    => 'wp_navigation',
            'post_status'  => 'publish',
            'post_content' => wp_slash($content),
        ]);
    }

    private function jsonRequest(array $body): \WP_REST_Request
    {
        $req = new \WP_REST_Request('POST', '/extendify/v1/quick-edit/wp-navigation');
        $req->set_header('Content-Type', 'application/json');
        $req->set_body(wp_json_encode($body));
        return $req;
    }

    private function loginAsAdmin(): void
    {
        $admin = self::factory()->user->create(['role' => 'administrator']);
        wp_set_current_user($admin);
    }

    private function resetRegistry(): void
    {
        $reflection = new ReflectionClass(Registry::class);
        $prop = $reflection->getProperty('schemas');
        $prop->setAccessible(true);
        $prop->setValue(null, []);
    }
}