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 / EditModeLifecycleTest.php

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

432 lines 15.8 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\Config;
6 use Extendify\PartnerData;
7 use Extendify\QuickEdit\Frontend;
8 use WP_UnitTestCase;
9
10 /**
11 * Characterizes Edit Mode lifecycle gates:
12 *
13 * - Frontend::registerAdminBar + Frontend::enqueue both gate on
14 * current_user_can(Config::$requiredCapability) — matches the
15 * plugin-wide bootstrap gate (default 'manage_options'). Editors
16 * do NOT see the pill or get the JS bundle.
17 *
18 * - The 'showQuickEdit' partner flag (or the 'quick-edit' preview
19 * opt-in) gates the inline-edit surfaces: the admin-bar toggle node
20 * only registers when enabled, and the inline payload carries
21 * 'quickEditEnabled' + a defaultOn that is false while QE is off.
22 * The selector JS bundle still enqueues either way — Ask AI rides
23 * on it independently.
24 */
25 class EditModeLifecycleTest extends WP_UnitTestCase
26 {
27 public static function setUpBeforeClass(): void
28 {
29 parent::setUpBeforeClass();
30 // The plugin bootstrap normally defines these inside an IIFE
31 // that does not run under PHPUnit. Default to non-DEVMODE so
32 // the enqueue path matches a production install.
33 if (!defined('EXTENDIFY_DEVMODE')) {
34 define('EXTENDIFY_DEVMODE', false);
35 }
36 if (!defined('EXTENDIFY_REQUIRED_CAPABILITY')) {
37 define('EXTENDIFY_REQUIRED_CAPABILITY', 'manage_options');
38 }
39 if (!defined('EXTENDIFY_PATH')) {
40 define('EXTENDIFY_PATH', dirname(__DIR__, 3) . '/');
41 }
42 if (!defined('EXTENDIFY_BASE_URL')) {
43 define('EXTENDIFY_BASE_URL', 'http://example.org/wp-content/plugins/extendify-sdk/');
44 }
45 // Stub the asset manifest rather than reading the real built file.
46 // public/build/ is gitignored and the PHPUnit CI job never runs the
47 // webpack build, so a disk read left the manifest empty there and the
48 // enqueue() assertions failed only in CI (passed locally off a stale
49 // build). A self-keyed stub is enough — enqueue() file_exists-guards
50 // the asset .php require. Mirrors Toolbar/FrontendTest::setManifestEntries.
51 Config::$assetManifest = [
52 'extendify-quick-edit.php' => 'extendify-quick-edit.php',
53 'extendify-quick-edit.js' => 'extendify-quick-edit.js',
54 'extendify-quick-edit.css' => 'extendify-quick-edit.css',
55 ];
56 }
57
58 public function tearDown(): void
59 {
60 remove_all_actions('admin_bar_menu');
61 remove_all_actions('wp_enqueue_scripts');
62 // Static PartnerData::$config leaks across tests/classes — reset the
63 // flag this suite flips so a later test doesn't inherit it.
64 $this->setPartnerSetting('showQuickEdit', false);
65 Config::$launchCompleted = false;
66 unset($GLOBALS['wp_customize']);
67 parent::tearDown();
68 }
69
70 public function test_constructor_hooks_admin_bar_menu_at_priority_100()
71 {
72 new Frontend();
73
74 $this->assertSame(100, has_action('admin_bar_menu', [
75 $this->latestFrontendInstance(),
76 'registerAdminBar',
77 ]));
78 }
79
80 public function test_constructor_hooks_wp_enqueue_scripts()
81 {
82 $frontend = new Frontend();
83
84 $this->assertNotFalse(has_action('wp_enqueue_scripts', [
85 $frontend,
86 'enqueue',
87 ]));
88 }
89
90 public function test_register_admin_bar_skips_when_logged_out()
91 {
92 wp_set_current_user(0);
93 $bar = $this->fakeAdminBar();
94
95 (new Frontend())->registerAdminBar($bar);
96
97 $this->assertSame([], $bar->nodes);
98 }
99
100 public function test_register_admin_bar_skips_in_admin_context()
101 {
102 $this->loginAs('administrator');
103 set_current_screen('edit-post');
104 $bar = $this->fakeAdminBar();
105
106 (new Frontend())->registerAdminBar($bar);
107
108 $this->assertSame([], $bar->nodes);
109 set_current_screen('front');
110 }
111
112 public function test_register_admin_bar_for_administrator_on_frontend()
113 {
114 $this->loginAs('administrator');
115 $this->setPartnerSetting('showQuickEdit', true);
116 $bar = $this->fakeAdminBar();
117
118 (new Frontend())->registerAdminBar($bar);
119
120 $this->assertCount(1, $bar->nodes);
121 $this->assertSame('extendify-quick-edit-toggle', $bar->nodes[0]['id']);
122 $this->assertStringContainsString('Quick Edit', $bar->nodes[0]['title']);
123 $this->assertSame('switch', $bar->nodes[0]['meta']['role']);
124 }
125
126 public function test_register_admin_bar_skips_for_editor_without_manage_options()
127 {
128 // Locked down: the pill follows Config::$requiredCapability
129 // (default manage_options). Editor role has edit_posts but not
130 // manage_options, so the pill must not register.
131 $this->loginAs('editor');
132 $bar = $this->fakeAdminBar();
133
134 (new Frontend())->registerAdminBar($bar);
135
136 $this->assertSame([], $bar->nodes);
137 }
138
139 public function test_register_admin_bar_skips_for_subscriber()
140 {
141 $this->loginAs('subscriber');
142 $bar = $this->fakeAdminBar();
143
144 (new Frontend())->registerAdminBar($bar);
145
146 $this->assertSame([], $bar->nodes);
147 }
148
149 public function test_enqueue_skips_when_user_lacks_required_capability()
150 {
151 // Editor (edit_posts but not manage_options) is the meaningful
152 // boundary post-lockdown; subscriber would skip anyway.
153 $this->loginAs('editor');
154
155 (new Frontend())->enqueue();
156
157 $this->assertFalse(wp_script_is('extendify-quick-edit', 'enqueued'));
158 $this->assertFalse(wp_script_is('extendify-quick-edit', 'registered'));
159 }
160
161 public function test_enqueue_skips_when_logged_out()
162 {
163 wp_set_current_user(0);
164
165 (new Frontend())->enqueue();
166
167 $this->assertFalse(wp_script_is('extendify-quick-edit', 'enqueued'));
168 }
169
170 public function test_enqueue_skips_in_admin_context()
171 {
172 $this->loginAs('administrator');
173 set_current_screen('edit-post');
174
175 (new Frontend())->enqueue();
176
177 $this->assertFalse(wp_script_is('extendify-quick-edit', 'enqueued'));
178 set_current_screen('front');
179 }
180
181 public function test_enqueue_skips_in_customize_preview()
182 {
183 // The Customizer preview iframe renders the front end (is_admin()
184 // false) for an admin user, so only is_customize_preview() tells QE
185 // apart from a normal front-end request — it must not enqueue there.
186 $this->loginAs('administrator');
187 require_once ABSPATH . 'wp-includes/class-wp-customize-manager.php';
188 $GLOBALS['wp_customize'] = new \WP_Customize_Manager();
189 $GLOBALS['wp_customize']->start_previewing_theme();
190
191 // Clear our handle so the assertion reflects THIS enqueue() call, not a
192 // handle a prior test left enqueued (WP_Scripts persists across tests).
193 wp_dequeue_script('extendify-quick-edit');
194
195 (new Frontend())->enqueue();
196
197 $this->assertFalse(wp_script_is('extendify-quick-edit', 'enqueued'));
198 }
199
200 public function test_enqueue_registers_inline_extQuickEditData_for_administrator()
201 {
202 $this->loginAs('administrator');
203
204 (new Frontend())->enqueue();
205
206 $this->assertTrue(wp_script_is('extendify-quick-edit', 'enqueued'));
207
208 $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
209 $this->assertStringContainsString('window.extQuickEditData', $inline);
210 $this->assertStringContainsString('"restRoot"', $inline);
211 $this->assertStringContainsString('"schemas"', $inline);
212 $this->assertStringContainsString('"defaultOn"', $inline);
213 }
214
215 public function test_enqueue_surfaces_translated_context_default_false()
216 {
217 // The payload always carries translatedContext so the client can gate
218 // text edits on a translated render. With no multilingual plugin active
219 // it reports the default-language shape. Detection per plugin is covered
220 // in QuickEdit/Services/TranslatedContextTest.
221 $this->loginAs('administrator');
222
223 (new Frontend())->enqueue();
224
225 $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
226 $payload = $this->extractInlineQuickEditData($inline);
227 $this->assertArrayHasKey('translatedContext', $payload);
228 $this->assertFalse($payload['translatedContext']['isTranslated']);
229 $this->assertNull($payload['translatedContext']['plugin']);
230 }
231
232 public function test_enqueue_inline_data_surfaces_currency_symbol_when_wc_is_loaded()
233 {
234 // The bootstrap requires WooCommerce under WP_CONTENT_DIR.
235 // If it didn't load, the producer side of this contract can't fire
236 // and the JS modal falls back to '$' even on non-USD stores.
237 if (!function_exists('get_woocommerce_currency_symbol')) {
238 $this->markTestSkipped('WooCommerce required for currency symbol surfacing.');
239 }
240
241 update_option('woocommerce_currency', 'EUR');
242
243 $this->loginAs('administrator');
244 (new Frontend())->enqueue();
245
246 $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
247 $this->assertStringContainsString('"currencySymbol"', $inline);
248 $payload = $this->extractInlineQuickEditData($inline);
249 $this->assertSame('', $payload['context']['currencySymbol']);
250 }
251
252 /**
253 * @return array<string,mixed>
254 */
255 private function extractInlineQuickEditData(string $inline): array
256 {
257 // The 'before' inline data accumulates one
258 // `window.extQuickEditData = {...};` block per enqueue() across the
259 // suite (WP_Scripts isn't reset between tests). The current test's
260 // block is the last one appended, so anchor on the final prefix and
261 // brace-walk (string-aware) to its matching close — a last-`}` scan
262 // would instead span every block and yield invalid JSON.
263 $prefix = 'window.extQuickEditData = ';
264 $start = strrpos($inline, $prefix);
265 $this->assertNotFalse($start, 'inline payload missing window.extQuickEditData');
266 $string = substr($inline, $start + strlen($prefix));
267 $json = '';
268 $depth = 0;
269 $inString = false;
270 $escaped = false;
271 for ($i = 0, $length = strlen($string); $i < $length; $i++) {
272 $char = $string[$i];
273 $json .= $char;
274 if ($inString) {
275 if ($escaped) {
276 $escaped = false;
277 } elseif ($char === '\\') {
278 $escaped = true;
279 } elseif ($char === '"') {
280 $inString = false;
281 }
282 continue;
283 }
284 if ($char === '"') {
285 $inString = true;
286 } elseif ($char === '{') {
287 $depth++;
288 } elseif ($char === '}') {
289 $depth--;
290 if ($depth === 0) {
291 break;
292 }
293 }
294 }
295
296 $decoded = json_decode($json, true);
297 $this->assertIsArray($decoded);
298 return $decoded;
299 }
300
301 public function test_enqueue_default_on_is_false_pre_launch()
302 {
303 // Pre-launch + non-DEVMODE: defaultOn is false. The user opts in
304 // by toggling the admin-bar pill; persist hydrates that choice
305 // on later visits.
306 Config::$launchCompleted = false;
307 $this->loginAs('administrator');
308
309 (new Frontend())->enqueue();
310
311 $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
312 $payload = $this->extractInlineQuickEditData($inline);
313 $this->assertFalse($payload['defaultOn']);
314 }
315
316 public function test_enqueue_default_on_is_true_post_launch()
317 {
318 // Post-launch installs with QE enabled land with edit mode engaged
319 // on first visit. The persisted toggle (zustand/persist) overrides
320 // this on later visits — see tests/unit/QuickEdit/state/edit-mode.test.js.
321 Config::$launchCompleted = true;
322 $this->loginAs('administrator');
323 $this->setPartnerSetting('showQuickEdit', true);
324
325 (new Frontend())->enqueue();
326
327 $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
328 $payload = $this->extractInlineQuickEditData($inline);
329 $this->assertTrue($payload['defaultOn']);
330
331 Config::$launchCompleted = false;
332 }
333
334 public function test_show_quick_edit_flag_gates_admin_bar_pill()
335 {
336 $this->loginAs('administrator');
337
338 // Flag off (default) → no toggle node.
339 $this->setPartnerSetting('showQuickEdit', false);
340 $barOff = $this->fakeAdminBar();
341 (new Frontend())->registerAdminBar($barOff);
342 $this->assertSame([], $barOff->nodes);
343
344 // Flag on → the Edit-mode toggle node registers.
345 $this->setPartnerSetting('showQuickEdit', true);
346 $barOn = $this->fakeAdminBar();
347 (new Frontend())->registerAdminBar($barOn);
348 $this->assertCount(1, $barOn->nodes);
349 $this->assertSame('extendify-quick-edit-toggle', $barOn->nodes[0]['id']);
350 }
351
352 public function test_enqueue_still_loads_selector_bundle_when_quick_edit_off()
353 {
354 // showQuickEdit gates the inline surfaces, never the bundle load —
355 // the selector ships for Ask AI regardless. quickEditEnabled rides
356 // in the payload so the JS can gate the pills.
357 $this->loginAs('administrator');
358 $this->setPartnerSetting('showQuickEdit', false);
359
360 (new Frontend())->enqueue();
361
362 $this->assertTrue(wp_script_is('extendify-quick-edit', 'enqueued'));
363 $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
364 $this->assertStringContainsString('"quickEditEnabled":false', $inline);
365 }
366
367 public function test_enqueue_surfaces_quick_edit_enabled_true_when_flag_on()
368 {
369 $this->loginAs('administrator');
370 $this->setPartnerSetting('showQuickEdit', true);
371
372 (new Frontend())->enqueue();
373
374 $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
375 $this->assertStringContainsString('"quickEditEnabled":true', $inline);
376 }
377
378 public function test_enqueue_default_on_false_when_quick_edit_off_even_post_launch()
379 {
380 // The post-Launch auto-on is suppressed while QE is off — the agent's
381 // Select button becomes the entry point instead. Asserted via the raw
382 // inline JSON to sidestep the env-drifted extractInlineQuickEditData
383 // helper.
384 Config::$launchCompleted = true;
385 $this->loginAs('administrator');
386 $this->setPartnerSetting('showQuickEdit', false);
387
388 (new Frontend())->enqueue();
389
390 $inline = wp_scripts()->get_inline_script_data('extendify-quick-edit', 'before');
391 $this->assertStringContainsString('"defaultOn":false', $inline);
392 }
393
394 private function loginAs(string $role): int
395 {
396 $user = self::factory()->user->create(['role' => $role]);
397 wp_set_current_user($user);
398 return $user;
399 }
400
401 private function fakeAdminBar(): object
402 {
403 return new class () {
404 public array $nodes = [];
405 public function add_node($node): void
406 {
407 $this->nodes[] = $node;
408 }
409 };
410 }
411
412 private function setPartnerSetting(string $key, $value): void
413 {
414 $prop = new \ReflectionProperty(PartnerData::class, 'config');
415 $prop->setAccessible(true);
416 $config = $prop->getValue();
417 $config[$key] = $value;
418 $prop->setValue(null, $config);
419 }
420
421 private function latestFrontendInstance(): Frontend
422 {
423 global $wp_filter;
424 foreach ($wp_filter['admin_bar_menu']->callbacks[100] ?? [] as $cb) {
425 if (is_array($cb['function']) && $cb['function'][0] instanceof Frontend) {
426 return $cb['function'][0];
427 }
428 }
429 $this->fail('No Frontend instance found on admin_bar_menu.');
430 }
431 }
432