| 1 |
<?php |
| 2 |
|
| 3 |
namespace Extendify\Tests\Integration\QuickEdit; |
| 4 |
|
| 5 |
use Extendify\Agent\TagBlocks; |
| 6 |
use Extendify\QuickEdit\Controllers\SaveController; |
| 7 |
use Extendify\QuickEdit\Schemas\Registry; |
| 8 |
use ReflectionClass; |
| 9 |
use WP_UnitTestCase; |
| 10 |
|
| 11 |
// Pins the QuickEdit <-> Agent seam: the |
| 12 |
// render-time tagger (Agent\TagBlocks) and the save-path re-derivation |
| 13 |
// (QuickEdit\SaveController::findBlock) are two hand-maintained twins of one |
| 14 |
// preorder-numbering convention. If they drift, the client clicks block N and |
| 15 |
// the server saves into a *different* block N — silent content corruption. |
| 16 |
// |
| 17 |
// Every other SaveControllerTest case hardcodes blockId (a human's manual |
| 18 |
// count), so none of them would catch the two walks diverging. These tests |
| 19 |
// instead run real content through the genuine the_content filter chain with |
| 20 |
// TagBlocks active, read the actual data-extendify-agent-block-id the tagger |
| 21 |
// emitted onto the rendered DOM, then feed THAT id back into handleSave — the |
| 22 |
// only way to assert the two numberings stay in lockstep, including across an |
| 23 |
// $ignored (loop) subtree where a divergent skip would shift every later id. |
| 24 |
class TagBlocksRoundTripTest extends WP_UnitTestCase |
| 25 |
{ |
| 26 |
public function setUp(): void |
| 27 |
{ |
| 28 |
parent::setUp(); |
| 29 |
$this->loginAsAdmin(); |
| 30 |
$this->resetRegistry(); |
| 31 |
Registry::init(); |
| 32 |
// WP_UnitTestCase restores $wp_filter per test, so this registers |
| 33 |
// TagBlocks' the_content/render_block filters exactly once per case — |
| 34 |
// no enterScope stacking that would push scope depth past 1. |
| 35 |
unset($GLOBALS['extendify_agent_scope']); |
| 36 |
TagBlocks::init(); |
| 37 |
} |
| 38 |
|
| 39 |
public function tearDown(): void |
| 40 |
{ |
| 41 |
$this->resetRegistry(); |
| 42 |
unset($GLOBALS['extendify_agent_scope']); |
| 43 |
parent::tearDown(); |
| 44 |
} |
| 45 |
|
| 46 |
// The core lockstep pin: an editable block sitting AFTER an $ignored loop |
| 47 |
// must get the same id from the render-time tagger and the save-path walk. |
| 48 |
// The loop's inner heading is counted by neither side — if either skip |
| 49 |
// diverged, "bravo" would tag/resolve at a different id and the save would |
| 50 |
// land on the wrong block (or on the loop's inner block). |
| 51 |
public function test_block_id_round_trips_across_an_ignored_loop() |
| 52 |
{ |
| 53 |
// 2 published posts so the post-template actually renders its inner |
| 54 |
// heading per item at render time — exercising the "skip a subtree that |
| 55 |
// renders once per loop item" path, not just an empty query. |
| 56 |
self::factory()->post->create(['post_status' => 'publish']); |
| 57 |
self::factory()->post->create(['post_status' => 'publish']); |
| 58 |
|
| 59 |
$content = '<!-- wp:heading --><h2>alpha</h2><!-- /wp:heading -->' |
| 60 |
. '<!-- wp:query {"queryId":1,"query":{"perPage":2,"postType":"post"}} -->' |
| 61 |
. '<div class="wp-block-query">' |
| 62 |
. '<!-- wp:post-template -->' |
| 63 |
. '<!-- wp:heading --><h2>inside-loop</h2><!-- /wp:heading -->' |
| 64 |
. '<!-- /wp:post-template -->' |
| 65 |
. '</div>' |
| 66 |
. '<!-- /wp:query -->' |
| 67 |
. '<!-- wp:heading --><h2>bravo</h2><!-- /wp:heading -->'; |
| 68 |
$postId = self::factory()->post->create(['post_content' => $content]); |
| 69 |
|
| 70 |
$rendered = $this->renderTagged($postId); |
| 71 |
|
| 72 |
// The tagger skipped the whole core/query subtree, so the loop's inner |
| 73 |
// heading carries no id at all. |
| 74 |
$this->assertNull( |
| 75 |
$this->idForText($rendered, 'h2', 'inside-loop'), |
| 76 |
'an $ignored loop subtree must not be tagged' |
| 77 |
); |
| 78 |
|
| 79 |
// Feed the id the tagger actually emitted onto "bravo" back to save. |
| 80 |
// No fingerprint on purpose: that isolates the count path so a |
| 81 |
// one-sided skip drift fails loudly (wrong-type 409 / wrong block) |
| 82 |
// instead of being silently recovered by fingerprint identity — the |
| 83 |
// recovery net is already pinned by SaveControllerTest's fingerprint |
| 84 |
// cases; this test exists to pin the numbering itself. |
| 85 |
$bravoId = $this->idForText($rendered, 'h2', 'bravo'); |
| 86 |
$this->assertNotNull($bravoId, 'the block after the loop must be tagged'); |
| 87 |
|
| 88 |
$res = SaveController::handleSave($this->jsonRequest([ |
| 89 |
'source' => ['kind' => 'post', 'id' => $postId], |
| 90 |
'blockId' => $bravoId, |
| 91 |
'blockType' => 'core/heading', |
| 92 |
'patches' => [['fieldKey' => 'content', 'value' => 'bravo-edited']], |
| 93 |
])); |
| 94 |
|
| 95 |
$this->assertSame(200, $res->get_status()); |
| 96 |
$stored = get_post($postId)->post_content; |
| 97 |
// The save resolved the SAME block the tagger pointed at: bravo edited, |
| 98 |
// its siblings (and the loop template) untouched. |
| 99 |
$this->assertStringContainsString('<h2>bravo-edited</h2>', $stored); |
| 100 |
$this->assertStringContainsString('<h2>alpha</h2>', $stored); |
| 101 |
$this->assertStringContainsString('<h2>inside-loop</h2>', $stored); |
| 102 |
} |
| 103 |
|
| 104 |
// The other half of the walk: nested (non-ignored) children ARE counted in |
| 105 |
// preorder by both sides. A heading inside a group must round-trip to the |
| 106 |
// group's child, not the group wrapper or a sibling. |
| 107 |
public function test_block_id_round_trips_through_nested_groups() |
| 108 |
{ |
| 109 |
$content = '<!-- wp:heading --><h2>outer</h2><!-- /wp:heading -->' |
| 110 |
. '<!-- wp:group {"layout":{"type":"constrained"}} -->' |
| 111 |
. '<div class="wp-block-group">' |
| 112 |
. '<!-- wp:heading --><h2>nested</h2><!-- /wp:heading -->' |
| 113 |
. '</div>' |
| 114 |
. '<!-- /wp:group -->' |
| 115 |
. '<!-- wp:heading --><h2>after</h2><!-- /wp:heading -->'; |
| 116 |
$postId = self::factory()->post->create(['post_content' => $content]); |
| 117 |
|
| 118 |
$rendered = $this->renderTagged($postId); |
| 119 |
|
| 120 |
$nestedId = $this->idForText($rendered, 'h2', 'nested'); |
| 121 |
$this->assertNotNull($nestedId, 'a nested non-ignored block must be tagged'); |
| 122 |
|
| 123 |
$res = SaveController::handleSave($this->jsonRequest([ |
| 124 |
'source' => ['kind' => 'post', 'id' => $postId], |
| 125 |
'blockId' => $nestedId, |
| 126 |
'blockType' => 'core/heading', |
| 127 |
'patches' => [['fieldKey' => 'content', 'value' => 'nested-edited']], |
| 128 |
])); |
| 129 |
|
| 130 |
$this->assertSame(200, $res->get_status()); |
| 131 |
$stored = get_post($postId)->post_content; |
| 132 |
$this->assertStringContainsString('<h2>nested-edited</h2>', $stored); |
| 133 |
$this->assertStringContainsString('<h2>outer</h2>', $stored); |
| 134 |
$this->assertStringContainsString('<h2>after</h2>', $stored); |
| 135 |
} |
| 136 |
|
| 137 |
// Render the post's content through the same the_content chain a live page |
| 138 |
// runs, with TagBlocks active, and return the tagged HTML. Mirrors |
| 139 |
// SaveController::renderBlockHtml's global-$post snapshot/restore so a |
| 140 |
// dangling global can't leak into other tests. |
| 141 |
private function renderTagged(int $postId): string |
| 142 |
{ |
| 143 |
$post = get_post($postId); |
| 144 |
$previousPost = $GLOBALS['post'] ?? null; |
| 145 |
$GLOBALS['post'] = $post; |
| 146 |
setup_postdata($post); |
| 147 |
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core WP filter |
| 148 |
$html = (string) apply_filters('the_content', $post->post_content); |
| 149 |
wp_reset_postdata(); |
| 150 |
$GLOBALS['post'] = $previousPost; |
| 151 |
return $html; |
| 152 |
} |
| 153 |
|
| 154 |
// The id the tagger appended to the first tag of $tag whose text is $text, |
| 155 |
// or null when that node carries no id (e.g. it sat inside an $ignored |
| 156 |
// subtree). Reads the real emitted attribute — the whole point of the pin. |
| 157 |
private function idForText(string $html, string $tag, string $text): ?int |
| 158 |
{ |
| 159 |
$dom = new \DOMDocument(); |
| 160 |
$previous = libxml_use_internal_errors(true); |
| 161 |
$dom->loadHTML('<?xml encoding="utf-8"?><div>' . $html . '</div>'); |
| 162 |
libxml_clear_errors(); |
| 163 |
libxml_use_internal_errors($previous); |
| 164 |
|
| 165 |
$xpath = new \DOMXPath($dom); |
| 166 |
foreach ($xpath->query("//{$tag}") as $node) { |
| 167 |
if (trim($node->textContent) === $text) { |
| 168 |
$id = $node->getAttribute('data-extendify-agent-block-id'); |
| 169 |
return $id === '' ? null : (int) $id; |
| 170 |
} |
| 171 |
} |
| 172 |
return null; |
| 173 |
} |
| 174 |
|
| 175 |
private function jsonRequest(array $body): \WP_REST_Request |
| 176 |
{ |
| 177 |
$req = new \WP_REST_Request('POST', '/extendify/v1/quick-edit/save'); |
| 178 |
$req->set_header('Content-Type', 'application/json'); |
| 179 |
$req->set_body(wp_json_encode($body)); |
| 180 |
return $req; |
| 181 |
} |
| 182 |
|
| 183 |
private function loginAsAdmin(): void |
| 184 |
{ |
| 185 |
$admin = self::factory()->user->create(['role' => 'administrator']); |
| 186 |
wp_set_current_user($admin); |
| 187 |
} |
| 188 |
|
| 189 |
private function resetRegistry(): void |
| 190 |
{ |
| 191 |
$reflection = new ReflectionClass(Registry::class); |
| 192 |
$prop = $reflection->getProperty('schemas'); |
| 193 |
$prop->setAccessible(true); |
| 194 |
$prop->setValue(null, []); |
| 195 |
} |
| 196 |
} |
| 197 |
|