From 625e71be6b854499bf2818fb7933d6084151de25 Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Tue, 12 May 2026 17:23:11 +0530 Subject: [PATCH 1/6] Editor: Add wp_apply_site_editor_post_lock filter and REST acquisition hook for templates. Adds wp_set_site_editor_post_lock_on_rest_prepare(), a small helper hooked to rest_prepare_wp_template and rest_prepare_wp_template_part that calls wp_set_post_lock() when a template is fetched with context=edit. The classic editor acquires its lock during edit-screen render in edit-form-advanced.php; Site Editor templates load over the REST API, so this fills the equivalent acquisition point for wp_template and wp_template_part. A new wp_apply_site_editor_post_lock filter lets real-time collaboration plugins (or future core RTC) opt the entire lock pipeline out with a single return false. No new Heartbeat handler is introduced; the existing wp_refresh_post_lock() in wp-admin/includes/misc.php is already post-type-agnostic and will be driven from the Site Editor in a follow-up commit. See #65126. --- src/wp-admin/includes/admin-filters.php | 4 ++ src/wp-admin/includes/post.php | 51 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/wp-admin/includes/admin-filters.php b/src/wp-admin/includes/admin-filters.php index 5337cc02c88c9..c70a9ccb4ea73 100644 --- a/src/wp-admin/includes/admin-filters.php +++ b/src/wp-admin/includes/admin-filters.php @@ -79,6 +79,10 @@ add_filter( 'heartbeat_received', 'wp_refresh_post_lock', 10, 3 ); add_filter( 'heartbeat_received', 'heartbeat_autosave', 500, 2 ); +// Site Editor post lock acquisition during REST edit-context reads. +add_filter( 'rest_prepare_wp_template', 'wp_set_site_editor_post_lock_on_rest_prepare', 10, 3 ); +add_filter( 'rest_prepare_wp_template_part', 'wp_set_site_editor_post_lock_on_rest_prepare', 10, 3 ); + add_filter( 'wp_refresh_nonces', 'wp_refresh_post_nonces', 10, 3 ); add_filter( 'wp_refresh_nonces', 'wp_refresh_metabox_loader_nonces', 10, 2 ); add_filter( 'wp_refresh_nonces', 'wp_refresh_heartbeat_nonces' ); diff --git a/src/wp-admin/includes/post.php b/src/wp-admin/includes/post.php index 8d52c1487a7a5..878ba7e28c34a 100644 --- a/src/wp-admin/includes/post.php +++ b/src/wp-admin/includes/post.php @@ -1778,6 +1778,57 @@ function wp_set_post_lock( $post ) { return array( $now, $user_id ); } +/** + * Acquires a Site Editor post lock during REST API edit-context responses. + * + * The classic editor calls {@see wp_set_post_lock()} when an edit screen renders, + * but Site Editor templates ({@see wp_template}, {@see wp_template_part}) are + * loaded over the REST API instead of through `post.php`. This function bridges + * that gap so the existing Heartbeat-based post locking workflow can be reused + * for the Site Editor. + * + * Hooked to `rest_prepare_wp_template` and `rest_prepare_wp_template_part`. The + * lock is only acquired when the request `context` is `edit` and the + * `wp_apply_site_editor_post_lock` filter does not opt out (for example, when a + * real-time collaboration plugin is active). + * + * @since 7.1.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post The post object being prepared. + * @param WP_REST_Request $request The current request. + * @return WP_REST_Response The unchanged response object. + */ +function wp_set_site_editor_post_lock_on_rest_prepare( $response, $post, $request ) { + if ( 'edit' !== $request['context'] ) { + return $response; + } + + if ( empty( $post ) || empty( $post->ID ) ) { + return $response; + } + + /** + * Filters whether the Site Editor post lock should be applied to a template. + * + * Returning false disables both lock acquisition during REST edit-context + * reads and the "Take over" flow on `site-editor.php`. Real-time + * collaboration plugins can short-circuit the entire lock pipeline here. + * + * @since 7.1.0 + * + * @param bool $apply Whether to apply the Site Editor post lock. Default true. + * @param WP_Post $post The template post being prepared. + */ + if ( ! apply_filters( 'wp_apply_site_editor_post_lock', true, $post ) ) { + return $response; + } + + wp_set_post_lock( $post ); + + return $response; +} + /** * Outputs the HTML for the notice to say that someone else is editing or has taken over editing of this post. * From 60f6ddb7f31c97c2244cf4b79697d7180e32b0f3 Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Tue, 12 May 2026 17:24:32 +0530 Subject: [PATCH 2/6] Editor: Handle get-post-lock query arg in the Site Editor. Resolves the current wp_template or wp_template_part post when site-editor.php loads with either the legacy ?postId= argument or the canonical ?p=/{post_type}/{post_id} form, and adds a get-post-lock=1 handler that mirrors the classic wp-admin/post.php take-over flow: - Verifies the lock-post_{id} nonce via check_admin_referer(). - Respects wp_apply_site_editor_post_lock so RTC plugins keep their opt-out. - Calls wp_set_post_lock() and redirects back to the editor without the take-over query args. Theme-supplied templates that have no numeric post ID yet are intentionally skipped; locking only starts once a real WP_Post exists in the database. See #65126. --- src/wp-admin/site-editor.php | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/wp-admin/site-editor.php b/src/wp-admin/site-editor.php index 9a8268c3392d7..4060ce25679b4 100644 --- a/src/wp-admin/site-editor.php +++ b/src/wp-admin/site-editor.php @@ -117,6 +117,56 @@ function _wp_get_site_editor_redirection_url() { exit; } +/* + * Resolve the template post being edited, if any. + * + * Site Editor URLs use the canonical `?p=/{post_type}/{post_id}` form once the + * redirection block above has run. Theme-supplied templates that have not been + * persisted yet (no numeric ID) are intentionally not resolved here; locking + * starts at the point a real WP_Post exists. + */ +$site_editor_post = null; +$site_editor_post_candidate = null; + +if ( ! empty( $_GET['postId'] ) && is_numeric( $_GET['postId'] ) ) { + $site_editor_post_candidate = get_post( (int) $_GET['postId'] ); +} elseif ( isset( $_GET['p'] ) && preg_match( '#^/(wp_template|wp_template_part)/(\d+)$#', $_GET['p'], $site_editor_post_matches ) ) { + $site_editor_post_candidate = get_post( (int) $site_editor_post_matches[2] ); +} + +if ( + $site_editor_post_candidate instanceof WP_Post && + in_array( $site_editor_post_candidate->post_type, array( 'wp_template', 'wp_template_part' ), true ) +) { + $site_editor_post = $site_editor_post_candidate; +} + +unset( $site_editor_post_candidate, $site_editor_post_matches ); + +/* + * Determine whether locking applies to the resolved template. Computed once so + * the take-over handler, lock acquisition, and dialog hook below all share a + * single filter dispatch. + */ +$site_editor_post_lock_enabled = false; + +if ( $site_editor_post ) { + /** This filter is documented in wp-admin/includes/post.php */ + $site_editor_post_lock_enabled = (bool) apply_filters( 'wp_apply_site_editor_post_lock', true, $site_editor_post ); +} + +// Handle "Take over" requests from the Site Editor post lock dialog. +if ( $site_editor_post && ! empty( $_GET['get-post-lock'] ) ) { + check_admin_referer( 'lock-post_' . $site_editor_post->ID ); + + if ( $site_editor_post_lock_enabled ) { + wp_set_post_lock( $site_editor_post->ID ); + } + + wp_safe_redirect( remove_query_arg( array( 'get-post-lock', '_wpnonce' ) ) ); + exit; +} + // Used in the HTML title tag. $title = _x( 'Editor', 'site editor title tag' ); $parent_file = 'themes.php'; From a3e7cf8fb3c0312a95afe9ff1c7c6d46765711db Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Tue, 12 May 2026 17:25:21 +0530 Subject: [PATCH 3/6] Editor: Render the post lock dialog on the Site Editor screen. Hooks _admin_notice_post_locked() to admin_footer when site-editor.php has resolved a wp_template or wp_template_part post and the wp_apply_site_editor_post_lock filter has not opted out. The existing notification-dialog markup from wp-admin/includes/post.php is reused as-is; the JS bridge introduced in a follow-up commit reveals it on a Heartbeat lock_error response and points the "Take over" button at the get-post-lock handler added in the previous commit. $GLOBALS['post'] is swapped to the resolved template post for the duration of the callback so _admin_notice_post_locked() resolves the correct WP_Post, mirroring how wp-admin/edit-form-advanced.php prepares the global before registering the same hook. See #65126. --- src/wp-admin/site-editor.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/wp-admin/site-editor.php b/src/wp-admin/site-editor.php index 4060ce25679b4..25720593571d1 100644 --- a/src/wp-admin/site-editor.php +++ b/src/wp-admin/site-editor.php @@ -167,6 +167,26 @@ function _wp_get_site_editor_redirection_url() { exit; } +/* + * Render the shared post lock dialog markup in the Site Editor footer. + * + * The classic editor hooks _admin_notice_post_locked() from + * wp-admin/edit-form-advanced.php; templates never go through that path, so + * the same hook is registered here with the resolved template post swapped + * into $GLOBALS['post'] for the duration of the callback. + */ +if ( $site_editor_post && $site_editor_post_lock_enabled ) { + add_action( + 'admin_footer', + static function () use ( $site_editor_post ) { + $previous_post = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null; + $GLOBALS['post'] = $site_editor_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restored below. + _admin_notice_post_locked(); + $GLOBALS['post'] = $previous_post; + } + ); +} + // Used in the HTML title tag. $title = _x( 'Editor', 'site editor title tag' ); $parent_file = 'themes.php'; From 48f669dea4c6d9e5b9adc684f161a0b2e22e958e Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Tue, 12 May 2026 17:28:18 +0530 Subject: [PATCH 4/6] Editor: Register the site-editor-post-lock script. Registers the new site-editor-post-lock script handle in wp_default_scripts() next to its closest neighbour, post. The script depends on jquery, heartbeat, wp-data, and wp-i18n. On site-editor.php: - Acquires the lock on initial render when no other user holds it, using the wp_check_post_lock() then wp_set_post_lock() pattern from wp-admin/post.php:191-192. - Enqueues heartbeat and the new bridge script only when a template post has been resolved and the wp_apply_site_editor_post_lock filter is truthy. - Localizes wpSiteEditorPostLockL10n with the post ID, post type, current lock string, and a pre-built take-over URL that targets the canonical ?p=/{post_type}/{post_id} form so the round-trip resolution in the take-over handler is unambiguous. The matching JS bridge that consumes this data is added in the next commit. See #65126. --- src/wp-admin/site-editor.php | 41 +++++++++++++++++++++++++++++++ src/wp-includes/script-loader.php | 3 +++ 2 files changed, 44 insertions(+) diff --git a/src/wp-admin/site-editor.php b/src/wp-admin/site-editor.php index 25720593571d1..672399879f4a8 100644 --- a/src/wp-admin/site-editor.php +++ b/src/wp-admin/site-editor.php @@ -167,6 +167,16 @@ function _wp_get_site_editor_redirection_url() { exit; } +/* + * Acquire (or refresh) the post lock for the current user when no other user + * holds it. Mirrors the wp-admin/post.php:191-192 pattern. + */ +$site_editor_active_post_lock = null; + +if ( $site_editor_post_lock_enabled && ! wp_check_post_lock( $site_editor_post->ID ) ) { + $site_editor_active_post_lock = wp_set_post_lock( $site_editor_post ); +} + /* * Render the shared post lock dialog markup in the Site Editor footer. * @@ -373,6 +383,37 @@ static function ( $classes ) { wp_enqueue_style( 'wp-format-library' ); wp_enqueue_media(); +// Site Editor post lock bridge. +if ( $site_editor_post && $site_editor_post_lock_enabled ) { + wp_enqueue_script( 'heartbeat' ); + wp_enqueue_script( 'site-editor-post-lock' ); + + wp_localize_script( + 'site-editor-post-lock', + 'wpSiteEditorPostLockL10n', + array( + 'enabled' => true, + 'postId' => $site_editor_post->ID, + 'postType' => $site_editor_post->post_type, + 'lock' => is_array( $site_editor_active_post_lock ) ? implode( ':', $site_editor_active_post_lock ) : '', + 'takeOverUrl' => add_query_arg( + 'get-post-lock', + '1', + wp_nonce_url( + add_query_arg( + array( + 'p' => '/' . $site_editor_post->post_type . '/' . $site_editor_post->ID, + 'canvas' => 'edit', + ), + admin_url( 'site-editor.php' ) + ), + 'lock-post_' . $site_editor_post->ID + ) + ), + ) + ); +} + if ( current_theme_supports( 'wp-block-styles' ) && ( ! is_array( $editor_styles ) || count( $editor_styles ) === 0 ) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 42d42b3f8781d..126e56ea5501d 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1449,6 +1449,9 @@ function wp_default_scripts( $scripts ) { $scripts->add( 'post', "/wp-admin/js/post$suffix.js", array( 'suggest', 'wp-lists', 'postbox', 'tags-box', 'underscore', 'word-count', 'wp-a11y', 'wp-sanitize', 'clipboard' ), false, 1 ); $scripts->set_translations( 'post' ); + $scripts->add( 'site-editor-post-lock', "/wp-admin/js/site-editor-post-lock$suffix.js", array( 'jquery', 'heartbeat', 'wp-data', 'wp-i18n' ), false, 1 ); + $scripts->set_translations( 'site-editor-post-lock' ); + $scripts->add( 'editor-expand', "/wp-admin/js/editor-expand$suffix.js", array( 'jquery', 'underscore' ), false, 1 ); $scripts->add( 'link', "/wp-admin/js/link$suffix.js", array( 'wp-lists', 'postbox' ), false, 1 ); From 100e7a513e13b1fa66cc3a72d7569a92b36cd9c0 Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Tue, 12 May 2026 17:30:49 +0530 Subject: [PATCH 5/6] Editor: Add the Heartbeat bridge for Site Editor post locks. The bridge is a thin classic-jQuery layer that connects the Site Editor to the existing wp-refresh-post-lock Heartbeat workflow: - Tracks the currently edited template entity via the core/edit-site data store so client-side navigation between templates is reflected in the Heartbeat payload. - On heartbeat-send, populates data['wp-refresh-post-lock'] with the active post ID and the most recent lock string. This is the same payload shape wp-admin/js/post.js sends from the classic editor, so the server handler wp_refresh_post_lock() needs no changes. - On heartbeat-tick, reveals the server-rendered #post-lock-dialog on lock_error and updates the cached lock token on new_lock. - Rewrites the dialog's "Take over" anchor to the canonical ?p=/{post_type}/{post_id} take-over URL so the server resolver in site-editor.php picks it up unambiguously. Bails out immediately when wpSiteEditorPostLockL10n.enabled is false, which is how the wp_apply_site_editor_post_lock filter propagates RTC opt-outs to the client side. See #65126. --- .../_enqueues/admin/site-editor-post-lock.js | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/js/_enqueues/admin/site-editor-post-lock.js diff --git a/src/js/_enqueues/admin/site-editor-post-lock.js b/src/js/_enqueues/admin/site-editor-post-lock.js new file mode 100644 index 0000000000000..cccffe377590d --- /dev/null +++ b/src/js/_enqueues/admin/site-editor-post-lock.js @@ -0,0 +1,132 @@ +/** + * @file Site Editor post lock bridge. + * + * Wires the Site Editor (wp_template, wp_template_part) into the existing + * Heartbeat-based post lock workflow. The bulk of the implementation lives in + * core: wp_refresh_post_lock() handles the server side, and the + * `#post-lock-dialog` markup is rendered by _admin_notice_post_locked() via + * site-editor.php. This file is the JS glue that: + * + * - Tracks the currently edited template entity through the core/edit-site + * data store so client-side navigation between templates is reflected in + * the Heartbeat payload. + * - Emits the same `wp-refresh-post-lock` payload that + * wp-admin/js/post.js sends from the classic editor. + * - Reveals the server-rendered lock dialog on a `lock_error` tick and + * stores the refreshed lock token on a `new_lock` tick. + * + * @output wp-admin/js/site-editor-post-lock.js + */ + +/* global wp */ + +( function ( $, settings ) { + 'use strict'; + + if ( ! settings || ! settings.enabled ) { + return; + } + + var initialPostId = parseInt( settings.postId, 10 ) || 0; + var activePostId = initialPostId; + var activeLock = String( settings.lock || '' ); + + /** + * Subscribe to the core/edit-site data store so the active post ID is + * always kept in sync when the user navigates between templates without + * a full page reload. + */ + if ( wp && wp.data && typeof wp.data.subscribe === 'function' ) { + wp.data.subscribe( function () { + var store = wp.data.select( 'core/edit-site' ); + if ( ! store ) { + return; + } + + var nextType = store.getEditedPostType(); + var nextId = store.getEditedPostId(); + + if ( + ( 'wp_template' === nextType || 'wp_template_part' === nextType ) && + 'number' === typeof nextId && + nextId !== activePostId + ) { + activePostId = nextId; + /* + * The new entity will receive its own lock token via the + * next Heartbeat tick. + */ + activeLock = ''; + } + } ); + } + + $( function () { + var $dialog = $( '#post-lock-dialog' ); + if ( $dialog.length && settings.takeOverUrl ) { + $dialog.find( '.button-primary.wp-tab-last' ).attr( 'href', settings.takeOverUrl ); + } + } ); + + $( document ) + /* + * Send the lock payload on every Heartbeat tick when we have a + * resolved template post. Mirrors `heartbeat-send.refresh-lock` + * in wp-admin/js/post.js. + */ + .on( 'heartbeat-send.refresh-site-editor-post-lock', function ( e, data ) { + if ( ! activePostId ) { + return; + } + + var send = { post_id: activePostId }; + + if ( activeLock ) { + send.lock = activeLock; + } + + data['wp-refresh-post-lock'] = send; + } ) + /* + * Handle the response: surface the server-rendered dialog on + * `lock_error`, or remember the refreshed lock token on + * `new_lock`. + */ + .on( 'heartbeat-tick.refresh-site-editor-post-lock', function ( e, data ) { + if ( ! data['wp-refresh-post-lock'] ) { + return; + } + + var received = data['wp-refresh-post-lock']; + + if ( received.lock_error ) { + var $wrap = $( '#post-lock-dialog' ); + + if ( $wrap.length && ! $wrap.is( ':visible' ) ) { + if ( received.lock_error.avatar_src ) { + var $avatar = $( '', { + 'class': 'avatar avatar-64 photo', + width: 64, + height: 64, + alt: '', + src: received.lock_error.avatar_src, + srcset: received.lock_error.avatar_src_2x ? + received.lock_error.avatar_src_2x + ' 2x' : + undefined + } ); + + $wrap.find( 'div.post-locked-avatar' ).empty().append( $avatar ); + } + + $wrap.removeClass( 'hidden' ) + .show() + .find( '.currently-editing' ).text( received.lock_error.text ); + + $wrap.find( '.wp-tab-first' ).trigger( 'focus' ); + } + } else if ( received.new_lock ) { + activeLock = received.new_lock; + } + } ); + +}( jQuery, window.wpSiteEditorPostLockL10n ) ); From 339708c8e5627d9ce4be9dea0cfe82ba440909d3 Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Tue, 12 May 2026 17:32:43 +0530 Subject: [PATCH 6/6] Editor: Tests for Site Editor post lock REST acquisition and filter. Adds Tests_Admin_WpSetSiteEditorPostLockOnRestPrepare covering: - Both rest_prepare_wp_template and rest_prepare_wp_template_part have the new callback registered at priority 10. - Edit-context responses acquire the lock for wp_template and wp_template_part. - View-context responses do not acquire any lock. - wp_apply_site_editor_post_lock returning false skips acquisition. - The filter receives the resolved WP_Post as its second argument. - Edit-context responses refresh the lock for the current user when another user had previously held it. The function is invoked directly rather than going through a full REST round-trip so the assertions are focused on the filter behaviour itself (matching the style of the rest of tests/phpunit/tests/admin/). See #65126. --- .../wpSetSiteEditorPostLockOnRestPrepare.php | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 tests/phpunit/tests/admin/wpSetSiteEditorPostLockOnRestPrepare.php diff --git a/tests/phpunit/tests/admin/wpSetSiteEditorPostLockOnRestPrepare.php b/tests/phpunit/tests/admin/wpSetSiteEditorPostLockOnRestPrepare.php new file mode 100644 index 0000000000000..a021c904bafdb --- /dev/null +++ b/tests/phpunit/tests/admin/wpSetSiteEditorPostLockOnRestPrepare.php @@ -0,0 +1,239 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$other_admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); + + self::$template_post = $factory->post->create_and_get( + array( + 'post_type' => 'wp_template', + 'post_name' => 'tests-65126-template', + 'post_title' => 'Tests 65126 Template', + 'post_status' => 'publish', + ) + ); + + self::$template_part_post = $factory->post->create_and_get( + array( + 'post_type' => 'wp_template_part', + 'post_name' => 'tests-65126-template-part', + 'post_title' => 'Tests 65126 Template Part', + 'post_status' => 'publish', + ) + ); + + self::$regular_post = $factory->post->create_and_get( + array( + 'post_type' => 'post', + 'post_title' => 'Tests 65126 Regular Post', + ) + ); + } + + public function set_up() { + parent::set_up(); + + // Ensure each test starts from a clean lock state. + delete_post_meta( self::$template_post->ID, '_edit_lock' ); + delete_post_meta( self::$template_part_post->ID, '_edit_lock' ); + delete_post_meta( self::$regular_post->ID, '_edit_lock' ); + + require_once ABSPATH . 'wp-admin/includes/post.php'; + + wp_set_current_user( self::$admin_id ); + } + + /** + * The filter callback is registered for both Site Editor post types. + */ + public function test_filters_are_registered() { + $this->assertSame( + 10, + has_filter( 'rest_prepare_wp_template', 'wp_set_site_editor_post_lock_on_rest_prepare' ), + 'rest_prepare_wp_template should have the lock acquisition filter registered.' + ); + $this->assertSame( + 10, + has_filter( 'rest_prepare_wp_template_part', 'wp_set_site_editor_post_lock_on_rest_prepare' ), + 'rest_prepare_wp_template_part should have the lock acquisition filter registered.' + ); + } + + /** + * Edit-context responses for `wp_template` acquire the lock. + */ + public function test_lock_is_acquired_for_wp_template_in_edit_context() { + $response = $this->run_rest_prepare( self::$template_post, 'edit' ); + + $this->assertInstanceOf( WP_REST_Response::class, $response, 'The response should be returned unchanged.' ); + $this->assertSame( + (int) self::$admin_id, + (int) $this->get_lock_user_id( self::$template_post->ID ), + 'The current user should hold the lock after an edit-context REST response.' + ); + } + + /** + * Edit-context responses for `wp_template_part` acquire the lock. + */ + public function test_lock_is_acquired_for_wp_template_part_in_edit_context() { + $this->run_rest_prepare( self::$template_part_post, 'edit' ); + + $this->assertSame( + (int) self::$admin_id, + (int) $this->get_lock_user_id( self::$template_part_post->ID ), + 'The current user should hold the lock after an edit-context REST response.' + ); + } + + /** + * View-context responses do not acquire the lock. + */ + public function test_lock_is_not_acquired_in_view_context() { + $this->run_rest_prepare( self::$template_post, 'view' ); + + $this->assertEmpty( + get_post_meta( self::$template_post->ID, '_edit_lock', true ), + 'View-context REST responses should not acquire a post lock.' + ); + } + + /** + * The `wp_apply_site_editor_post_lock` filter can opt out of acquisition. + */ + public function test_filter_can_opt_out_of_lock_acquisition() { + add_filter( 'wp_apply_site_editor_post_lock', '__return_false' ); + + try { + $this->run_rest_prepare( self::$template_post, 'edit' ); + } finally { + remove_filter( 'wp_apply_site_editor_post_lock', '__return_false' ); + } + + $this->assertEmpty( + get_post_meta( self::$template_post->ID, '_edit_lock', true ), + 'wp_apply_site_editor_post_lock returning false should skip acquisition.' + ); + } + + /** + * The filter receives the resolved post object as its second argument. + */ + public function test_filter_receives_post_object() { + $captured = null; + + $capture = static function ( $apply, $post ) use ( &$captured ) { + $captured = $post; + return $apply; + }; + + add_filter( 'wp_apply_site_editor_post_lock', $capture, 10, 2 ); + + try { + $this->run_rest_prepare( self::$template_post, 'edit' ); + } finally { + remove_filter( 'wp_apply_site_editor_post_lock', $capture, 10 ); + } + + $this->assertInstanceOf( WP_Post::class, $captured, 'The filter should receive a WP_Post instance.' ); + $this->assertSame( self::$template_post->ID, $captured->ID, 'The filter should receive the template post being prepared.' ); + } + + /** + * A lock already held by another user is not overwritten silently. The + * existing wp_set_post_lock() helper simply refreshes the timestamp using + * the current user, so when an admin opens an edit-context request after + * another admin had the lock, the lock now belongs to the new user. This + * verifies that acquisition does happen (the function does not bail out + * because a lock already exists). + */ + public function test_lock_is_refreshed_for_current_user() { + // Pretend another admin opened the template first. + wp_set_current_user( self::$other_admin_id ); + wp_set_post_lock( self::$template_post->ID ); + + wp_set_current_user( self::$admin_id ); + + $this->run_rest_prepare( self::$template_post, 'edit' ); + + $this->assertSame( + (int) self::$admin_id, + (int) $this->get_lock_user_id( self::$template_post->ID ), + 'An edit-context REST response should refresh the lock for the current user.' + ); + } + + /** + * Helper: invoke the REST prepare filter directly with a minimal response + * and a fake request whose context can be controlled per test. + * + * @param WP_Post $post The post being prepared. + * @param string $context Either `edit` or `view`. + * @return WP_REST_Response + */ + protected function run_rest_prepare( WP_Post $post, $context ) { + $response = new WP_REST_Response( array( 'id' => $post->ID ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/' . ( 'wp_template' === $post->post_type ? 'templates' : 'template-parts' ) . '/' . $post->ID ); + $request->set_param( 'context', $context ); + + return wp_set_site_editor_post_lock_on_rest_prepare( $response, $post, $request ); + } + + /** + * Helper: read the user ID portion of the `_edit_lock` meta value. + * + * @param int $post_id Post ID to read. + * @return int Lock user ID, or 0 when no lock is present. + */ + protected function get_lock_user_id( $post_id ) { + $lock = get_post_meta( $post_id, '_edit_lock', true ); + + if ( ! $lock ) { + return 0; + } + + $parts = explode( ':', $lock ); + + return isset( $parts[1] ) ? (int) $parts[1] : 0; + } +}