PluginProbe
Extendify / 3.1.0
Extendify v3.1.0
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / tests / Integration / QuickEdit / TagBlocksRoundTripTest.php

TagBlocksRoundTripTest.php in Extendify 3.1.0, at tests/Integration/QuickEdit/TagBlocksRoundTripTest.php

197 lines 8.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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