From 85be6435094358841b2aaba570027f2cc287009a Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:08 -0400 Subject: [PATCH 01/42] Collaboration: Add dedicated database table and storage backend Introduces the wp_collaboration table for storing real-time editing data (document states, awareness info, undo history) and the WP_Collaboration_Table_Storage class that implements all CRUD operations against it. Bumps the database schema version to 61840. --- src/wp-admin/includes/schema.php | 12 + src/wp-admin/includes/upgrade.php | 2 +- src/wp-includes/class-wpdb.php | 10 + .../class-wp-collaboration-table-storage.php | 310 ++++++++++++++++++ src/wp-includes/version.php | 2 +- 5 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 src/wp-includes/collaboration/class-wp-collaboration-table-storage.php diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 2e142197dc21c..36d39b7b5d497 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -186,6 +186,18 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { KEY post_parent (post_parent), KEY post_author (post_author), KEY type_status_author (post_type,post_status,post_author) +) $charset_collate; +CREATE TABLE $wpdb->collaboration ( + id bigint(20) unsigned NOT NULL auto_increment, + room varchar($max_index_length) NOT NULL default '', + type varchar(32) NOT NULL default '', + client_id varchar(32) NOT NULL default '', + user_id bigint(20) unsigned NOT NULL default '0', + update_value longtext NOT NULL, + date_gmt datetime NOT NULL default '0000-00-00 00:00:00', + PRIMARY KEY (id), + KEY room (room,id), + KEY date_gmt (date_gmt) ) $charset_collate;\n"; // Single site users table. The multisite flavor of the users table is handled below. diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 6adb0521ff295..0fcfe4acd1077 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -886,7 +886,7 @@ function upgrade_all() { upgrade_682(); } - if ( $wp_current_db_version < 61644 ) { + if ( $wp_current_db_version < 61840 ) { upgrade_700(); } diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index 23c865b87d817..f4da31dc57b39 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -299,6 +299,7 @@ class wpdb { 'term_relationships', 'termmeta', 'commentmeta', + 'collaboration', ); /** @@ -404,6 +405,15 @@ class wpdb { */ public $posts; + /** + * WordPress Collaboration table. + * + * @since 7.0.0 + * + * @var string + */ + public $collaboration; + /** * WordPress Terms table. * diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php new file mode 100644 index 0000000000000..f60508ad1c53d --- /dev/null +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -0,0 +1,310 @@ +, user_id: int} + */ +class WP_Collaboration_Table_Storage { + /** + * Cache of cursors by room. + * + * @since 7.0.0 + * @var array + */ + private array $room_cursors = array(); + + /** + * Cache of update counts by room. + * + * @since 7.0.0 + * @var array + */ + private array $room_update_counts = array(); + + /** + * Adds an update to a given room. + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param mixed $update Update data. + * @return bool True on success, false on failure. + */ + public function add_update( string $room, $update ): bool { + global $wpdb; + + $result = $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => $update['type'] ?? '', + 'client_id' => $update['client_id'] ?? '', + 'update_value' => wp_json_encode( $update ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + ), + array( '%s', '%s', '%s', '%s', '%s' ) + ); + + return false !== $result; + } + + /** + * Gets awareness state for a given room. + * + * Retrieves per-client awareness rows from the collaboration table + * where type = 'awareness'. Expired rows are filtered by the WHERE + * clause; actual deletion is handled by cron via + * wp_delete_old_collaboration_data(). + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param int $timeout Seconds before an awareness entry is considered expired. + * @return array Awareness entries. + * @phpstan-return list + */ + public function get_awareness_state( string $room, int $timeout = 30 ): array { + global $wpdb; + + $cutoff = gmdate( 'Y-m-d H:i:s', time() - $timeout ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT client_id, user_id, update_value FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND date_gmt >= %s", + $room, + $cutoff + ) + ); + + if ( ! is_array( $rows ) ) { + return array(); + } + + $entries = array(); + foreach ( $rows as $row ) { + $decoded = json_decode( $row->update_value, true ); + if ( is_array( $decoded ) ) { + $entries[] = array( + 'client_id' => $row->client_id, + 'state' => $decoded, + 'user_id' => (int) $row->user_id, + ); + } + } + + return $entries; + } + + /** + * Gets the current cursor for a given room. + * + * The cursor is set during get_updates_after_cursor() and represents the + * maximum row ID at the time updates were retrieved. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Current cursor for the room. + */ + public function get_cursor( string $room ): int { + return $this->room_cursors[ $room ] ?? 0; + } + + /** + * Gets the number of updates stored for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Number of updates stored for the room. + */ + public function get_update_count( string $room ): int { + return $this->room_update_counts[ $room ] ?? 0; + } + + /** + * Retrieves updates from a room after a given cursor. + * + * Uses a snapshot approach: captures MAX(id) and COUNT(*) in a single + * query, then fetches rows WHERE id > cursor AND id <= max_id. Updates + * arriving after the snapshot are deferred to the next poll, never lost. + * + * Only retrieves non-awareness rows — awareness rows are handled + * separately via get_awareness_state(). + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param int $cursor Return updates after this cursor. + * @return array Updates. + */ + public function get_updates_after_cursor( string $room, int $cursor ): array { + global $wpdb; + + // Snapshot the current max ID and total row count in a single query. + // Excludes awareness rows — they are not sync updates. + $snapshot = $wpdb->get_row( + $wpdb->prepare( + "SELECT COALESCE( MAX( id ), 0 ) AS max_id, COUNT(*) AS total FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'", + $room + ) + ); + + if ( ! $snapshot ) { + $this->room_cursors[ $room ] = 0; + $this->room_update_counts[ $room ] = 0; + return array(); + } + + $max_id = (int) $snapshot->max_id; + $total = (int) $snapshot->total; + + $this->room_cursors[ $room ] = $max_id; + + if ( 0 === $max_id || $max_id <= $cursor ) { + // Preserve the real row count so the server can still + // trigger compaction when updates have accumulated but + // no new ones arrived since the client's last poll. + $this->room_update_counts[ $room ] = $total; + return array(); + } + + $this->room_update_counts[ $room ] = $total; + + // Fetch updates after the cursor up to the snapshot boundary. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT update_value FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id > %d AND id <= %d ORDER BY id ASC", + $room, + $cursor, + $max_id + ) + ); + + if ( ! is_array( $rows ) ) { + return array(); + } + + $updates = array(); + foreach ( $rows as $row ) { + $decoded = json_decode( $row->update_value, true ); + if ( is_array( $decoded ) ) { + $updates[] = $decoded; + } + } + + return $updates; + } + + /** + * Removes updates from a room that are older than the given cursor. + * + * Uses a single atomic DELETE query, avoiding the race-prone + * "delete all, re-add some" pattern. + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param int $cursor Remove updates with id <= this cursor. + * @return bool True on success, false on failure. + */ + public function remove_updates_before_cursor( string $room, int $cursor ): bool { + global $wpdb; + + $result = $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id <= %d", + $room, + $cursor + ) + ); + + return false !== $result; + } + + /** + * Sets awareness state for a given client in a room. + * + * Uses UPDATE-then-INSERT: tries to update the existing row first, + * and only inserts if no row was updated. Each client writes only + * its own row, eliminating the race condition inherent in shared-state + * approaches. + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param string $client_id Client identifier. + * @param array $state Serializable awareness state for this client. + * @param int $user_id WordPress user ID that owns this client. + * @return bool True on success, false on failure. + */ + public function set_awareness_state( string $room, string $client_id, array $state, int $user_id ): bool { + global $wpdb; + + $update_value = wp_json_encode( $state ); + $now = gmdate( 'Y-m-d H:i:s' ); + + // Try UPDATE first. + $updated = $wpdb->update( + $wpdb->collaboration, + array( + 'user_id' => $user_id, + 'update_value' => $update_value, + 'date_gmt' => $now, + ), + array( + 'room' => $room, + 'type' => 'awareness', + 'client_id' => $client_id, + ) + ); + + // INSERT only if no existing row. + if ( 0 === (int) $updated ) { + $result = $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => 'awareness', + 'client_id' => $client_id, + 'user_id' => $user_id, + 'update_value' => $update_value, + 'date_gmt' => $now, + ) + ); + + return false !== $result; + } + + return false !== $updated; + } +} diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 733850aa6eb21..02a9f4bc06025 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -23,7 +23,7 @@ * * @global int $wp_db_version */ -$wp_db_version = 61833; +$wp_db_version = 61840; /** * Holds the TinyMCE version. From a4f8b9892bed463073ca69711b7d136b8231337b Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:14 -0400 Subject: [PATCH 02/42] Collaboration: Replace sync server with collaboration server Replaces WP_HTTP_Polling_Sync_Server with WP_HTTP_Polling_Collaboration_Server using the wp-collaboration/v1 REST namespace. Switches to string-based client IDs, fixes the compaction race condition, adds a backward-compatible wp-sync/v1 route alias, and uses UPDATE-then-INSERT for awareness data. --- ...-wp-http-polling-collaboration-server.php} | 254 ++++++++++-------- 1 file changed, 146 insertions(+), 108 deletions(-) rename src/wp-includes/collaboration/{class-wp-http-polling-sync-server.php => class-wp-http-polling-collaboration-server.php} (61%) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php similarity index 61% rename from src/wp-includes/collaboration/class-wp-http-polling-sync-server.php rename to src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index 88554a48c7d54..f36d4ba1bdb09 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -1,8 +1,9 @@ storage = $storage; } @@ -96,8 +98,9 @@ public function register_routes(): void { $typed_update_args = array( 'properties' => array( 'data' => array( - 'type' => 'string', - 'required' => true, + 'type' => 'string', + 'required' => true, + 'maxLength' => 1048576, // 1 MB — generous ceiling for base64-encoded Yjs updates. ), 'type' => array( 'type' => 'string', @@ -125,14 +128,17 @@ public function register_routes(): void { 'type' => array( 'object', 'null' ), ), 'client_id' => array( - 'minimum' => 1, - 'required' => true, - 'type' => 'integer', + 'required' => true, + 'type' => array( 'string', 'integer' ), + 'sanitize_callback' => function ( $value ) { + return (string) $value; + }, ), 'room' => array( - 'required' => true, - 'type' => 'string', - 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', + 'required' => true, + 'type' => 'string', + 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', + 'maxLength' => 191, // Matches $max_index_length in wp-admin/includes/schema.php. ), 'updates' => array( 'items' => $typed_update_args, @@ -142,30 +148,51 @@ public function register_routes(): void { ), ); + $route_args = array( + 'methods' => array( WP_REST_Server::CREATABLE ), + 'callback' => array( $this, 'handle_request' ), + 'permission_callback' => array( $this, 'check_permissions' ), + 'args' => array( + 'rooms' => array( + 'items' => array( + 'properties' => $room_args, + 'type' => 'object', + ), + 'required' => true, + 'type' => 'array', + ), + ), + ); + register_rest_route( self::REST_NAMESPACE, '/updates', - array( - 'methods' => array( WP_REST_Server::CREATABLE ), - 'callback' => array( $this, 'handle_request' ), - 'permission_callback' => array( $this, 'check_permissions' ), - 'args' => array( - 'rooms' => array( - 'items' => array( - 'properties' => $room_args, - 'type' => 'object', - ), - 'required' => true, - 'type' => 'array', - ), - ), - ) + $route_args + ); + + /* + * Backward-compatible alias so that the Gutenberg plugin's + * bundled sync package (which still uses wp-sync/v1) continues + * to work against WordPress 7.0+. + * + * @todo Remove once the Gutenberg plugin has transitioned to + * the wp-collaboration/v1 namespace. + */ + register_rest_route( + 'wp-sync/v1', + '/updates', + $route_args ); } /** * Checks if the current user has permission to access a room. * + * Requires `edit_posts` (contributor+), then delegates to + * can_user_collaborate_on_entity_type() for per-entity checks. + * There is no dedicated `collaborate` capability; access follows + * existing edit capabilities for the entity type. + * * @since 7.0.0 * * @param WP_REST_Request $request The REST request. @@ -176,29 +203,15 @@ public function check_permissions( WP_REST_Request $request ) { if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'rest_cannot_edit', - __( 'You do not have permission to perform this action' ), + __( 'You do not have permission to perform this action.' ), array( 'status' => rest_authorization_required_code() ) ); } - $rooms = $request['rooms']; - $wp_user_id = get_current_user_id(); + $rooms = $request['rooms']; foreach ( $rooms as $room ) { - $client_id = $room['client_id']; - $room = $room['room']; - - // Check that the client_id is not already owned by another user. - $existing_awareness = $this->storage->get_awareness_state( $room ); - foreach ( $existing_awareness as $entry ) { - if ( $client_id === $entry['client_id'] && $wp_user_id !== $entry['wp_user_id'] ) { - return new WP_Error( - 'rest_cannot_edit', - __( 'Client ID is already in use by another user.' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - } + $room = $room['room']; $type_parts = explode( '/', $room, 2 ); $object_parts = explode( ':', $type_parts[1] ?? '', 2 ); @@ -207,13 +220,13 @@ public function check_permissions( WP_REST_Request $request ) { $entity_name = $object_parts[0]; $object_id = $object_parts[1] ?? null; - if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { + if ( ! $this->can_user_collaborate_on_entity_type( $entity_kind, $entity_name, $object_id ) ) { return new WP_Error( 'rest_cannot_edit', sprintf( - /* translators: %s: The room name encodes the current entity being synced. */ - __( 'You do not have permission to sync this entity: %s.' ), - $room + /* translators: %s: The room name identifying the collaborative editing session. */ + __( 'You do not have permission to collaborate on this entity: %s.' ), + esc_html( $room ) ), array( 'status' => rest_authorization_required_code() ) ); @@ -224,7 +237,7 @@ public function check_permissions( WP_REST_Request $request ) { } /** - * Handles request: stores sync updates and awareness data, and returns + * Handles request: stores updates and awareness data, and returns * updates the client is missing. * * @since 7.0.0 @@ -244,18 +257,22 @@ public function handle_request( WP_REST_Request $request ) { $cursor = $room_request['after']; $room = $room_request['room']; - // Merge awareness state. + // Merge awareness state (also validates client_id ownership). $merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness ); + if ( is_wp_error( $merged_awareness ) ) { + return $merged_awareness; + } + // The lowest client ID is nominated to perform compaction when needed. $is_compactor = false; if ( count( $merged_awareness ) > 0 ) { - $is_compactor = min( array_keys( $merged_awareness ) ) === $client_id; + $is_compactor = (string) min( array_keys( $merged_awareness ) ) === $client_id; } // Process each update according to its type. foreach ( $room_request['updates'] as $update ) { - $result = $this->process_sync_update( $room, $client_id, $cursor, $update ); + $result = $this->process_collaboration_update( $room, $client_id, $cursor, $update ); if ( is_wp_error( $result ) ) { return $result; } @@ -272,7 +289,7 @@ public function handle_request( WP_REST_Request $request ) { } /** - * Checks if the current user can sync a specific entity type. + * Checks if the current user can collaborate on a specific entity type. * * @since 7.0.0 * @@ -281,7 +298,7 @@ public function handle_request( WP_REST_Request $request ) { * @param string|null $object_id The object ID / entity key for single entities, null for collections. * @return bool True if user has permission, otherwise false. */ - private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool { + private function can_user_collaborate_on_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool { // Handle single post type entities with a defined object ID. if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { return current_user_can( 'edit_post', (int) $object_id ); @@ -314,7 +331,7 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ return current_user_can( $post_type_object->cap->edit_posts ); } - // Collection syncing does not exchange entity data. It only signals if + // Collection collaboration does not exchange entity data. It only signals if // another user has updated an entity in the collection. Therefore, we only // compare against an allow list of collection types. $allowed_collection_entity_kinds = array( @@ -329,66 +346,66 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ /** * Processes and stores an awareness update from a client. * + * Also validates that the client_id is not already owned by another user. + * This check uses the same get_awareness_state() query that builds the + * response, eliminating a duplicate query that was previously performed + * in check_permissions(). + * * @since 7.0.0 * * @param string $room Room identifier. - * @param int $client_id Client identifier. + * @param string $client_id Client identifier. * @param array|null $awareness_update Awareness state sent by the client. - * @return array> Map of client ID to awareness state. + * @return array>|WP_Error Map of client ID to awareness state, or WP_Error if client_id is owned by another user. */ - private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { - $existing_awareness = $this->storage->get_awareness_state( $room ); - $updated_awareness = array(); - $current_time = time(); - - foreach ( $existing_awareness as $entry ) { - // Remove this client's entry (it will be updated below). - if ( $client_id === $entry['client_id'] ) { - continue; - } + private function process_awareness_update( string $room, string $client_id, ?array $awareness_update ) { + $wp_user_id = get_current_user_id(); - // Remove entries that have expired. - if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) { - continue; - } + // Check ownership before upserting so a hijacked client_id is rejected. + $entries = $this->storage->get_awareness_state( $room, self::AWARENESS_TIMEOUT ); - $updated_awareness[] = $entry; + foreach ( $entries as $entry ) { + if ( $client_id === $entry['client_id'] && $wp_user_id !== $entry['user_id'] ) { + return new WP_Error( + 'rest_cannot_edit', + __( 'Client ID is already in use by another user.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } } - // Add this client's awareness state. if ( null !== $awareness_update ) { - $updated_awareness[] = array( - 'client_id' => $client_id, - 'state' => $awareness_update, - 'updated_at' => $current_time, - 'wp_user_id' => get_current_user_id(), - ); + $this->storage->set_awareness_state( $room, $client_id, $awareness_update, $wp_user_id ); } - // This action can fail, but it shouldn't fail the entire request. - $this->storage->set_awareness_state( $room, $updated_awareness ); - - // Convert to client_id => state map for response. $response = array(); - foreach ( $updated_awareness as $entry ) { + foreach ( $entries as $entry ) { $response[ $entry['client_id'] ] = $entry['state']; } + // Other clients' states were decoded from the DB. Run the current + // client's state through the same encode/decode path so the response + // is consistent — wp_json_encode may normalize values (e.g. strip + // invalid UTF-8) that would otherwise differ on the next poll. + if ( null !== $awareness_update ) { + $response[ $client_id ] = json_decode( wp_json_encode( $awareness_update ), true ); + } + return $response; } /** - * Processes a sync update based on its type. + * Processes a collaboration update based on its type. * * @since 7.0.0 * * @param string $room Room identifier. - * @param int $client_id Client identifier. + * @param string $client_id Client identifier. * @param int $cursor Client cursor (marker of last seen update). - * @param array{data: string, type: string} $update Sync update. + * @param array{data: string, type: string} $update Collaboration update. * @return true|WP_Error True on success, WP_Error on storage failure. */ - private function process_sync_update( string $room, int $client_id, int $cursor, array $update ) { + private function process_collaboration_update( string $room, string $client_id, int $cursor, array $update ) { $data = $update['data']; $type = $update['type']; @@ -397,7 +414,7 @@ private function process_sync_update( string $room, int $client_id, int $cursor, /* * Compaction replaces updates the client has already seen. Only remove * updates with markers before the client's cursor to preserve updates - * that arrived since the client's last sync. + * that arrived since the client's last poll. * * Check for a newer compaction update first. If one exists, skip this * compaction to avoid overwriting it. @@ -413,15 +430,31 @@ private function process_sync_update( string $room, int $client_id, int $cursor, } if ( ! $has_newer_compaction ) { + // Insert the compaction row before deleting old rows. + // Reversing the order closes a race window where a + // client joining with cursor=0 between the DELETE and + // INSERT would see an empty room for one poll cycle. + // The compaction row always has a higher ID than the + // deleted rows, so cursor-based filtering is unaffected. + $insert_result = $this->add_update( $room, $client_id, $type, $data ); + if ( is_wp_error( $insert_result ) ) { + return $insert_result; + } + if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { + global $wpdb; + $error_data = array( 'status' => 500 ); + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + $error_data['db_error'] = $wpdb->last_error; + } return new WP_Error( - 'rest_sync_storage_error', + 'rest_collaboration_storage_error', __( 'Failed to remove updates during compaction.' ), - array( 'status' => 500 ) + $error_data ); } - return $this->add_update( $room, $client_id, $type, $data ); + return true; } // Reaching this point means there's a newer compaction, so we can @@ -445,7 +478,7 @@ private function process_sync_update( string $room, int $client_id, int $cursor, return new WP_Error( 'rest_invalid_update_type', - __( 'Invalid sync update type.' ), + __( 'Invalid collaboration update type.' ), array( 'status' => 400 ) ); } @@ -456,12 +489,12 @@ private function process_sync_update( string $room, int $client_id, int $cursor, * @since 7.0.0 * * @param string $room Room identifier. - * @param int $client_id Client identifier. + * @param string $client_id Client identifier. * @param string $type Update type (sync_step1, sync_step2, update, compaction). * @param string $data Base64-encoded update data. * @return true|WP_Error True on success, WP_Error on storage failure. */ - private function add_update( string $room, int $client_id, string $type, string $data ) { + private function add_update( string $room, string $client_id, string $type, string $data ) { $update = array( 'client_id' => $client_id, 'data' => $data, @@ -469,10 +502,15 @@ private function add_update( string $room, int $client_id, string $type, string ); if ( ! $this->storage->add_update( $room, $update ) ) { + global $wpdb; + $data = array( 'status' => 500 ); + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + $data['db_error'] = $wpdb->last_error; + } return new WP_Error( - 'rest_sync_storage_error', - __( 'Failed to store sync update.' ), - array( 'status' => 500 ) + 'rest_collaboration_storage_error', + __( 'Failed to store collaboration update.' ), + $data ); } @@ -480,7 +518,7 @@ private function add_update( string $room, int $client_id, string $type, string } /** - * Gets sync updates for a specific client from a room after a given cursor. + * Gets updates for a specific client from a room after a given cursor. * * Delegates cursor-based retrieval to the storage layer, then applies * client-specific filtering and compaction logic. @@ -488,7 +526,7 @@ private function add_update( string $room, int $client_id, string $type, string * @since 7.0.0 * * @param string $room Room identifier. - * @param int $client_id Client identifier. + * @param string $client_id Client identifier. * @param int $cursor Return updates after this cursor. * @param bool $is_compactor True if this client is nominated to perform compaction. * @return array{ @@ -499,7 +537,7 @@ private function add_update( string $room, int $client_id, string $type, string * updates: array, * } Response data for this room. */ - private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array { + private function get_updates( string $room, string $client_id, int $cursor, bool $is_compactor ): array { $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor ); $total_updates = $this->storage->get_update_count( $room ); From da8317c30f182a5c82b601387cf2b86d7c1ca067 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:21 -0400 Subject: [PATCH 03/42] Collaboration: Remove legacy post meta storage and post type Deletes WP_Sync_Post_Meta_Storage and WP_Sync_Storage interface, and removes the wp_sync_storage post type registration from post.php. These are superseded by the dedicated collaboration table. --- .../class-wp-sync-post-meta-storage.php | 322 ------------------ .../interface-wp-sync-storage.php | 86 ----- src/wp-includes/post.php | 35 -- 3 files changed, 443 deletions(-) delete mode 100644 src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php delete mode 100644 src/wp-includes/collaboration/interface-wp-sync-storage.php diff --git a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php deleted file mode 100644 index c605fa48699b7..0000000000000 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ /dev/null @@ -1,322 +0,0 @@ - - */ - private array $room_cursors = array(); - - /** - * Cache of update counts by room. - * - * @since 7.0.0 - * @var array - */ - private array $room_update_counts = array(); - - /** - * Cache of storage post IDs by room hash. - * - * @since 7.0.0 - * @var array - */ - private static array $storage_post_ids = array(); - - /** - * Adds a sync update to a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param mixed $update Sync update. - * @return bool True on success, false on failure. - */ - public function add_update( string $room, $update ): bool { - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - // Create an envelope and stamp each update to enable cursor-based filtering. - $envelope = array( - 'timestamp' => $this->get_time_marker(), - 'value' => $update, - ); - - return (bool) add_post_meta( $post_id, wp_slash( self::SYNC_UPDATE_META_KEY ), wp_slash( $envelope ), false ); - } - - /** - * Retrieves all sync updates for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return array Sync updates. - */ - private function get_all_updates( string $room ): array { - $this->room_cursors[ $room ] = $this->get_time_marker() - 100; // Small buffer to ensure consistency. - - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return array(); - } - - $updates = get_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, false ); - - if ( ! is_array( $updates ) ) { - $updates = array(); - } - - // Filter out any updates that don't have the expected structure. - $updates = array_filter( - $updates, - static function ( $update ): bool { - return is_array( $update ) && isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] ); - } - ); - - $this->room_update_counts[ $room ] = count( $updates ); - - return $updates; - } - - /** - * Gets awareness state for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return array Awareness state. - */ - public function get_awareness_state( string $room ): array { - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return array(); - } - - $awareness = get_post_meta( $post_id, self::AWARENESS_META_KEY, true ); - - if ( ! is_array( $awareness ) ) { - return array(); - } - - return array_values( $awareness ); - } - - /** - * Sets awareness state for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param array $awareness Serializable awareness state. - * @return bool True on success, false on failure. - */ - public function set_awareness_state( string $room, array $awareness ): bool { - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - // update_post_meta returns false if the value is the same as the existing value. - update_post_meta( $post_id, wp_slash( self::AWARENESS_META_KEY ), wp_slash( $awareness ) ); - return true; - } - - /** - * Gets the current cursor for a given room. - * - * The cursor is set during get_updates_after_cursor() and represents the - * point in time just before the updates were retrieved, with a small buffer - * to ensure consistency. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return int Current cursor for the room. - */ - public function get_cursor( string $room ): int { - return $this->room_cursors[ $room ] ?? 0; - } - - /** - * Gets or creates the storage post for a given room. - * - * Each room gets its own dedicated post so that post meta cache - * invalidation is scoped to a single room rather than all of them. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return int|null Post ID. - */ - private function get_storage_post_id( string $room ): ?int { - $room_hash = md5( $room ); - - if ( isset( self::$storage_post_ids[ $room_hash ] ) ) { - return self::$storage_post_ids[ $room_hash ]; - } - - // Try to find an existing post for this room. - $posts = get_posts( - array( - 'post_type' => self::POST_TYPE, - 'posts_per_page' => 1, - 'post_status' => 'publish', - 'name' => $room_hash, - 'fields' => 'ids', - ) - ); - - $post_id = array_first( $posts ); - if ( is_int( $post_id ) ) { - self::$storage_post_ids[ $room_hash ] = $post_id; - return $post_id; - } - - // Create new post for this room. - $post_id = wp_insert_post( - array( - 'post_type' => self::POST_TYPE, - 'post_status' => 'publish', - 'post_title' => 'Sync Storage', - 'post_name' => $room_hash, - ) - ); - - if ( is_int( $post_id ) ) { - self::$storage_post_ids[ $room_hash ] = $post_id; - return $post_id; - } - - return null; - } - - /** - * Gets the current time in milliseconds as a comparable time marker. - * - * @since 7.0.0 - * - * @return int Current time in milliseconds. - */ - private function get_time_marker(): int { - return (int) floor( microtime( true ) * 1000 ); - } - - /** - * Gets the number of updates stored for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return int Number of updates stored for the room. - */ - public function get_update_count( string $room ): int { - return $this->room_update_counts[ $room ] ?? 0; - } - - /** - * Retrieves sync updates from a room for a given client and cursor. Updates - * from the specified client should be excluded. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param int $cursor Return updates after this cursor. - * @return array Sync updates. - */ - public function get_updates_after_cursor( string $room, int $cursor ): array { - $all_updates = $this->get_all_updates( $room ); - $updates = array(); - - foreach ( $all_updates as $update ) { - if ( $update['timestamp'] > $cursor ) { - $updates[] = $update; - } - } - - // Sort by timestamp to ensure order. - usort( - $updates, - fn ( $a, $b ) => $a['timestamp'] <=> $b['timestamp'] - ); - - return wp_list_pluck( $updates, 'value' ); - } - - /** - * Removes updates from a room that are older than the given cursor. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param int $cursor Remove updates with markers < this cursor. - * @return bool True on success, false on failure. - */ - public function remove_updates_before_cursor( string $room, int $cursor ): bool { - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - $all_updates = $this->get_all_updates( $room ); - - // Remove all updates for the room and re-store only those that are newer than the cursor. - if ( ! delete_post_meta( $post_id, wp_slash( self::SYNC_UPDATE_META_KEY ) ) ) { - return false; - } - - // Re-store envelopes directly to avoid double-wrapping by add_update(). - $add_result = true; - foreach ( $all_updates as $envelope ) { - if ( $add_result && $envelope['timestamp'] >= $cursor ) { - $add_result = (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false ); - } - } - - return $add_result; - } -} diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php deleted file mode 100644 index d84dbeb1e4aae..0000000000000 --- a/src/wp-includes/collaboration/interface-wp-sync-storage.php +++ /dev/null @@ -1,86 +0,0 @@ - Awareness state. - */ - public function get_awareness_state( string $room ): array; - - /** - * Gets the current cursor for a given room. This should return a monotonically - * increasing integer that represents the last update that was returned for the - * room during the current request. This allows clients to retrieve updates - * after a specific cursor on subsequent requests. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return int Current cursor for the room. - */ - public function get_cursor( string $room ): int; - - /** - * Gets the total number of stored updates for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return int Total number of updates. - */ - public function get_update_count( string $room ): int; - - /** - * Retrieves sync updates from a room for a given client and cursor. Updates - * from the specified client should be excluded. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param int $cursor Return updates after this cursor. - * @return array Sync updates. - */ - public function get_updates_after_cursor( string $room, int $cursor ): array; - - /** - * Removes updates from a room that are older than the provided cursor. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param int $cursor Remove updates with markers < this cursor. - * @return bool True on success, false on failure. - */ - public function remove_updates_before_cursor( string $room, int $cursor ): bool; - - /** - * Sets awareness state for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param array $awareness Serializable awareness state. - * @return bool True on success, false on failure. - */ - public function set_awareness_state( string $room, array $awareness ): bool; -} diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 896142603278b..55d934518d5f0 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -657,41 +657,6 @@ function create_initial_post_types() { ) ); - if ( get_option( 'wp_enable_real_time_collaboration' ) ) { - register_post_type( - 'wp_sync_storage', - array( - 'labels' => array( - 'name' => __( 'Sync Updates' ), - 'singular_name' => __( 'Sync Update' ), - ), - 'public' => false, - '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ - 'hierarchical' => false, - 'capabilities' => array( - 'read' => 'do_not_allow', - 'read_private_posts' => 'do_not_allow', - 'create_posts' => 'do_not_allow', - 'publish_posts' => 'do_not_allow', - 'edit_posts' => 'do_not_allow', - 'edit_others_posts' => 'do_not_allow', - 'edit_published_posts' => 'do_not_allow', - 'delete_posts' => 'do_not_allow', - 'delete_others_posts' => 'do_not_allow', - 'delete_published_posts' => 'do_not_allow', - ), - 'map_meta_cap' => false, - 'publicly_queryable' => false, - 'query_var' => false, - 'rewrite' => false, - 'show_in_menu' => false, - 'show_in_rest' => false, - 'show_ui' => false, - 'supports' => array( 'custom-fields' ), - ) - ); - } - register_post_status( 'publish', array( From b06269e8236141caee9a5b2e9ab7ea3725edf6a2 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:28 -0400 Subject: [PATCH 04/42] Collaboration: Wire up bootstrap, feature gate, and cron cleanup Adds wp_is_collaboration_enabled() gate, injects the collaboration setting into the block editor, registers cron event for cleaning up stale collaboration data, and updates require/include paths for the new storage and server classes. --- src/wp-admin/admin.php | 8 +++++ src/wp-includes/collaboration.php | 50 ++++++++++++++++++++++++++++- src/wp-includes/default-filters.php | 1 + src/wp-includes/rest-api.php | 8 ++--- src/wp-settings.php | 5 ++- 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/wp-admin/admin.php b/src/wp-admin/admin.php index 82ab6b93ac99e..3634c8c29c20d 100644 --- a/src/wp-admin/admin.php +++ b/src/wp-admin/admin.php @@ -113,6 +113,14 @@ wp_schedule_event( time(), 'daily', 'delete_expired_transients' ); } +// Schedule collaboration data cleanup. +if ( wp_is_collaboration_enabled() + && ! wp_next_scheduled( 'wp_delete_old_collaboration_data' ) + && ! wp_installing() +) { + wp_schedule_event( time(), 'daily', 'wp_delete_old_collaboration_data' ); +} + set_screen_options(); $date_format = __( 'F j, Y' ); diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 31f816c87b670..1ad489deac89a 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -6,6 +6,21 @@ * @since 7.0.0 */ +/** + * Checks whether real-time collaboration is enabled. + * + * The feature requires both the site option and the database schema + * introduced in db_version 61840. + * + * @since 7.0.0 + * + * @return bool True if collaboration is enabled, false otherwise. + */ +function wp_is_collaboration_enabled() { + return get_option( 'wp_enable_real_time_collaboration' ) + && get_option( 'db_version' ) >= 61840; +} + /** * Injects the real-time collaboration setting into a global variable. * @@ -18,7 +33,7 @@ function wp_collaboration_inject_setting() { global $pagenow; - if ( ! get_option( 'wp_enable_real_time_collaboration' ) ) { + if ( ! wp_is_collaboration_enabled() ) { return; } @@ -34,3 +49,36 @@ function wp_collaboration_inject_setting() { 'after' ); } + +/** + * Deletes stale collaboration data from the collaboration table. + * + * Removes non-awareness rows older than 7 days and awareness rows older + * than 60 seconds. Rows left behind by abandoned collaborative editing + * sessions are cleaned up to prevent unbounded table growth. + * + * @since 7.0.0 + */ +function wp_delete_old_collaboration_data() { + if ( ! wp_is_collaboration_enabled() ) { + return; + } + + global $wpdb; + + // Clean up sync rows older than 7 days. + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE type != 'awareness' AND date_gmt < %s", + gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) + ) + ); + + // Clean up awareness rows older than 60 seconds. + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE type = 'awareness' AND date_gmt < %s", + gmdate( 'Y-m-d H:i:s', time() - 60 ) + ) + ); +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 24b808bf9cd17..68a2ecec70254 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -454,6 +454,7 @@ add_action( 'importer_scheduled_cleanup', 'wp_delete_attachment' ); add_action( 'upgrader_scheduled_cleanup', 'wp_delete_attachment' ); add_action( 'delete_expired_transients', 'delete_expired_transients' ); +add_action( 'wp_delete_old_collaboration_data', 'wp_delete_old_collaboration_data' ); // Navigation menu actions. add_action( 'delete_post', '_wp_delete_post_menu_item' ); diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index df7f262d3aa58..a1e7fd2cea7de 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -430,10 +430,10 @@ function create_initial_rest_routes() { $icons_controller->register_routes(); // Collaboration. - if ( get_option( 'wp_enable_real_time_collaboration' ) ) { - $sync_storage = new WP_Sync_Post_Meta_Storage(); - $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); - $sync_server->register_routes(); + if ( wp_is_collaboration_enabled() ) { + $collaboration_storage = new WP_Collaboration_Table_Storage(); + $collaboration_server = new WP_HTTP_Polling_Collaboration_Server( $collaboration_storage ); + $collaboration_server->register_routes(); } } diff --git a/src/wp-settings.php b/src/wp-settings.php index dab1d8fd4c0de..f7e09e2c10ea4 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -310,9 +310,8 @@ require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php'; require ABSPATH . WPINC . '/abilities-api.php'; require ABSPATH . WPINC . '/abilities.php'; -require ABSPATH . WPINC . '/collaboration/interface-wp-sync-storage.php'; -require ABSPATH . WPINC . '/collaboration/class-wp-sync-post-meta-storage.php'; -require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-sync-server.php'; +require ABSPATH . WPINC . '/collaboration/class-wp-collaboration-table-storage.php'; +require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-collaboration-server.php'; require ABSPATH . WPINC . '/collaboration.php'; require ABSPATH . WPINC . '/rest-api.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php'; From 886f0b127078b0f5e0ef301966ef60622ebecd1e Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:37 -0400 Subject: [PATCH 05/42] Tests: Add collaboration server tests and remove legacy sync tests Adds 67 PHPUnit tests for WP_HTTP_Polling_Collaboration_Server covering document sync, awareness, undo/redo, compaction, permissions, cursor mechanics, race conditions, cron cleanup, and the backward-compatible wp-sync/v1 route. Adds E2E tests for 3-user presence, sync, and undo/redo. Removes the old sync server tests. Updates REST schema setup and fixtures for the new collaboration endpoints. --- .../collaboration-presence.test.js | 109 + .../collaboration/collaboration-sync.test.js | 353 ++++ .../collaboration-undo-redo.test.js | 181 ++ .../fixtures/collaboration-utils.js | 426 ++++ .../e2e/specs/collaboration/fixtures/index.js | 48 + .../rest-api/rest-collaboration-server.php | 1769 +++++++++++++++++ .../tests/rest-api/rest-schema-setup.php | 13 +- .../tests/rest-api/rest-sync-server.php | 867 -------- tests/qunit/fixtures/wp-api-generated.js | 154 +- 9 files changed, 3028 insertions(+), 892 deletions(-) create mode 100644 tests/e2e/specs/collaboration/collaboration-presence.test.js create mode 100644 tests/e2e/specs/collaboration/collaboration-sync.test.js create mode 100644 tests/e2e/specs/collaboration/collaboration-undo-redo.test.js create mode 100644 tests/e2e/specs/collaboration/fixtures/collaboration-utils.js create mode 100644 tests/e2e/specs/collaboration/fixtures/index.js create mode 100644 tests/phpunit/tests/rest-api/rest-collaboration-server.php delete mode 100644 tests/phpunit/tests/rest-api/rest-sync-server.php diff --git a/tests/e2e/specs/collaboration/collaboration-presence.test.js b/tests/e2e/specs/collaboration/collaboration-presence.test.js new file mode 100644 index 0000000000000..600794405ffb5 --- /dev/null +++ b/tests/e2e/specs/collaboration/collaboration-presence.test.js @@ -0,0 +1,109 @@ +/** + * Tests for collaborative editing presence (awareness). + * + * Verifies that collaborator avatars, names, and leave events + * propagate correctly between three concurrent users. + * + * @package WordPress + * @since 7.0.0 + */ + +/** + * Internal dependencies + */ +import { test, expect, SYNC_TIMEOUT } from './fixtures'; + +test.describe( 'Collaboration - Presence', () => { + test( 'All 3 collaborator avatars are visible', async ( { + collaborationUtils, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Presence Test - 3 Users', + } ); + + const { page2, page3 } = collaborationUtils; + + // Each user sees the collaborators list button (indicates others are present). + await expect( + page.getByRole( 'button', { name: /Collaborators list/ } ) + ).toBeVisible( { timeout: SYNC_TIMEOUT } ); + + await expect( + page2.getByRole( 'button', { name: /Collaborators list/ } ) + ).toBeVisible( { timeout: SYNC_TIMEOUT } ); + + await expect( + page3.getByRole( 'button', { name: /Collaborators list/ } ) + ).toBeVisible( { timeout: SYNC_TIMEOUT } ); + } ); + + test( 'Collaborator names appear in popover', async ( { + collaborationUtils, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Presence Test - Names', + } ); + + // User A opens the collaborators popover. + const presenceButton = page.getByRole( 'button', { + name: /Collaborators list/, + } ); + await expect( presenceButton ).toBeVisible( { + timeout: SYNC_TIMEOUT, + } ); + await presenceButton.click(); + + // The popover should list both collaborators by name. + // Use the presence list item class to avoid matching snackbar toasts. + await expect( + page.locator( '.editor-collaborators-presence__list-item-name', { hasText: 'Test Collaborator' } ) + ).toBeVisible(); + + await expect( + page.locator( '.editor-collaborators-presence__list-item-name', { hasText: 'Another Collaborator' } ) + ).toBeVisible(); + } ); + + test( 'User C leaves, A and B see updated presence', async ( { + collaborationUtils, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Presence Test - Leave', + } ); + + // Verify all 3 users see the collaborators button initially. + await expect( + page.getByRole( 'button', { name: /Collaborators list/ } ) + ).toBeVisible( { timeout: SYNC_TIMEOUT } ); + + // Navigate User C away from the editor to stop their polling. + // Avoids closing the context directly which corrupts Playwright state. + await collaborationUtils.page3.goto( '/wp-admin/' ); + + // Wait for User C's awareness entry to expire on the server (30s timeout) + // by watching the button label drop from 3 to 2 collaborators. + const presenceButton = page.getByRole( 'button', { + name: /Collaborators list/, + } ); + await expect( presenceButton ).toHaveAccessibleName( + /2 online/, + { timeout: 45000 } + ); + + // Open the popover once, then verify the list contents. + await presenceButton.click(); + + // "Another Collaborator" (User C) should no longer appear in the presence list. + await expect( + page.locator( '.editor-collaborators-presence__list-item-name', { hasText: 'Another Collaborator' } ) + ).not.toBeVisible(); + + // "Test Collaborator" (User B) should still be listed. + await expect( + page.locator( '.editor-collaborators-presence__list-item-name', { hasText: 'Test Collaborator' } ) + ).toBeVisible(); + } ); +} ); diff --git a/tests/e2e/specs/collaboration/collaboration-sync.test.js b/tests/e2e/specs/collaboration/collaboration-sync.test.js new file mode 100644 index 0000000000000..5bf51d2a979fe --- /dev/null +++ b/tests/e2e/specs/collaboration/collaboration-sync.test.js @@ -0,0 +1,353 @@ +/** + * Tests for collaborative editing sync (CRDT document replication). + * + * Verifies that block insertions, deletions, edits, title changes, + * and late-join state transfer propagate correctly between three + * concurrent users. + * + * @package WordPress + * @since 7.0.0 + */ + +/** + * Internal dependencies + */ +import { test, expect, SYNC_TIMEOUT } from './fixtures'; + +test.describe( 'Collaboration - Sync', () => { + test( 'User A adds a paragraph block, Users B and C both see it', async ( { + collaborationUtils, + editor, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - Fan Out', + } ); + + const { editor2, editor3 } = collaborationUtils; + + // User A inserts a paragraph block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Hello from User A' }, + } ); + + // User B should see the paragraph after sync propagation. + await expect + .poll( () => editor2.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Hello from User A' }, + }, + ] ); + + // User C should also see the paragraph. + await expect + .poll( () => editor3.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Hello from User A' }, + }, + ] ); + } ); + + test( 'User C adds a paragraph block, Users A and B see it', async ( { + collaborationUtils, + editor, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - C to A and B', + } ); + + const { editor2, page3 } = collaborationUtils; + + // User C inserts a paragraph block via the data API. + await collaborationUtils.insertBlockViaEvaluate( + page3, + 'core/paragraph', + { content: 'Hello from User C' } + ); + + // User A should see the paragraph. + await expect + .poll( () => editor.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Hello from User C' }, + }, + ] ); + + // User B should also see the paragraph. + await expect + .poll( () => editor2.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Hello from User C' }, + }, + ] ); + } ); + + test( 'All 3 users add blocks simultaneously, all changes appear everywhere', async ( { + collaborationUtils, + editor, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - 3-Way Merge', + } ); + + const { page2, page3 } = collaborationUtils; + + // All 3 users insert blocks concurrently. + await Promise.all( [ + editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'From User A' }, + } ), + collaborationUtils.insertBlockViaEvaluate( + page2, + 'core/paragraph', + { content: 'From User B' } + ), + collaborationUtils.insertBlockViaEvaluate( + page3, + 'core/paragraph', + { content: 'From User C' } + ), + ] ); + + // All 3 users should eventually see all 3 blocks. + await collaborationUtils.assertAllEditorsHaveContent( [ + 'From User A', + 'From User B', + 'From User C', + ] ); + } ); + + test( 'Title change from User A propagates to B and C', async ( { + collaborationUtils, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - Title', + } ); + + const { page2, page3 } = collaborationUtils; + + // User A changes the title. + await page.evaluate( () => { + window.wp.data + .dispatch( 'core/editor' ) + .editPost( { title: 'New Title from User A' } ); + } ); + + // User B should see the updated title. + await expect + .poll( + () => + page2.evaluate( () => + window.wp.data + .select( 'core/editor' ) + .getEditedPostAttribute( 'title' ) + ), + { timeout: SYNC_TIMEOUT } + ) + .toBe( 'New Title from User A' ); + + // User C should also see the updated title. + await expect + .poll( + () => + page3.evaluate( () => + window.wp.data + .select( 'core/editor' ) + .getEditedPostAttribute( 'title' ) + ), + { timeout: SYNC_TIMEOUT } + ) + .toBe( 'New Title from User A' ); + } ); + + test( 'User C joins late and sees existing content from A and B', async ( { + collaborationUtils, + editor, + } ) => { + const post = await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - Late Join', + } ); + + const { page2, page3, editor3 } = collaborationUtils; + + // Navigate User C away from the editor to simulate not being + // present while A and B make edits. + await page3.goto( '/wp-admin/' ); + + // User A and B each add a block while User C is away. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Block from A (early)' }, + } ); + + await collaborationUtils.insertBlockViaEvaluate( + page2, + 'core/paragraph', + { content: 'Block from B (early)' } + ); + + // Wait for A and B to sync with each other. + await collaborationUtils.assertEditorHasContent( editor, [ + 'Block from A (early)', + 'Block from B (early)', + ] ); + + // Now User C joins late by navigating back to the editor. + await collaborationUtils.navigateToEditor( page3, post.id ); + await collaborationUtils.waitForCollaborationReady( page3 ); + + // User C should see all existing blocks from A and B after sync. + await collaborationUtils.assertEditorHasContent( editor3, [ + 'Block from A (early)', + 'Block from B (early)', + ] ); + } ); + + test( 'Block deletion syncs to all users', async ( { + collaborationUtils, + editor, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - Block Deletion', + content: + '

Block to delete

', + } ); + + const { editor2, editor3 } = collaborationUtils; + + // Wait for all users to see the seeded block. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect + .poll( () => ed.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Block to delete' }, + }, + ] ); + } + + // User A removes the block. + await page.evaluate( () => { + const blocks = window.wp.data + .select( 'core/block-editor' ) + .getBlocks(); + window.wp.data + .dispatch( 'core/block-editor' ) + .removeBlock( blocks[ 0 ].clientId ); + } ); + + // Users B and C should see 0 blocks after sync. + await expect + .poll( () => editor2.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toHaveLength( 0 ); + + await expect + .poll( () => editor3.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toHaveLength( 0 ); + } ); + + test( 'Editing existing block content syncs to all users', async ( { + collaborationUtils, + editor, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - Edit Content', + content: + '

Original text

', + } ); + + const { editor2, editor3, page2 } = collaborationUtils; + + // Wait for all users to see the seeded block. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect + .poll( () => ed.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Original text' }, + }, + ] ); + } + + // User B updates the block content. + await page2.evaluate( () => { + const blocks = window.wp.data + .select( 'core/block-editor' ) + .getBlocks(); + window.wp.data + .dispatch( 'core/block-editor' ) + .updateBlockAttributes( blocks[ 0 ].clientId, { + content: 'Edited by User B', + } ); + } ); + + // Users A and C should see the updated content. + await expect + .poll( () => editor.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Edited by User B' }, + }, + ] ); + + await expect + .poll( () => editor3.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Edited by User B' }, + }, + ] ); + } ); + + test( 'Non-paragraph block type syncs to all users', async ( { + collaborationUtils, + editor, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Sync Test - Heading Block', + } ); + + const { editor2, editor3 } = collaborationUtils; + + // User A inserts a heading block. + await editor.insertBlock( { + name: 'core/heading', + attributes: { content: 'Synced Heading', level: 3 }, + } ); + + // User B should see the heading with correct attributes. + await expect + .poll( () => editor2.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/heading', + attributes: { content: 'Synced Heading', level: 3 }, + }, + ] ); + + // User C should also see the heading with correct attributes. + await expect + .poll( () => editor3.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/heading', + attributes: { content: 'Synced Heading', level: 3 }, + }, + ] ); + } ); +} ); diff --git a/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js b/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js new file mode 100644 index 0000000000000..dce4e5b2e548b --- /dev/null +++ b/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js @@ -0,0 +1,181 @@ +/** + * Tests for collaborative editing undo/redo. + * + * Verifies that undo and redo operations affect only the originating + * user's changes while preserving other collaborators' edits. + * + * @package WordPress + * @since 7.0.0 + */ + +/** + * Internal dependencies + */ +import { test, expect, SYNC_TIMEOUT } from './fixtures'; + +test.describe( 'Collaboration - Undo/Redo', () => { + test( 'User A undo only affects their own changes, B and C blocks remain', async ( { + collaborationUtils, + editor, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Undo Test - 3 Users', + } ); + + const { page2, page3 } = collaborationUtils; + + // User B adds a block. + await collaborationUtils.insertBlockViaEvaluate( + page2, + 'core/paragraph', + { content: 'From User B' } + ); + + // User C adds a block. + await collaborationUtils.insertBlockViaEvaluate( + page3, + 'core/paragraph', + { content: 'From User C' } + ); + + // Wait for both blocks to appear on User A. + await collaborationUtils.assertEditorHasContent( editor, [ + 'From User B', + 'From User C', + ] ); + + // User A adds their own block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'From User A' }, + } ); + + // Wait for all 3 blocks to appear on all editors. + await collaborationUtils.assertAllEditorsHaveContent( [ + 'From User A', + 'From User B', + 'From User C', + ] ); + + // User A performs undo via the data API. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).undo(); + } ); + + // All users should see only B and C's blocks (A's is undone). + await collaborationUtils.assertAllEditorsHaveContent( + [ 'From User B', 'From User C' ], + { not: [ 'From User A' ] } + ); + } ); + + test( 'Redo restores the undone change across all users', async ( { + collaborationUtils, + editor, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Redo Test - 3 Users', + } ); + + const { editor2, editor3 } = collaborationUtils; + + // User A adds a block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Undoable content' }, + } ); + + // Verify the block exists on all editors. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect + .poll( () => ed.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Undoable content' }, + }, + ] ); + } + + // Undo via data API. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).undo(); + } ); + + await expect + .poll( () => editor.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toHaveLength( 0 ); + + // Redo via data API. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).redo(); + } ); + + // All users should see the restored block. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect + .poll( () => ed.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Undoable content' }, + }, + ] ); + } + } ); + + test( 'Bystander sees correct state after undo', async ( { + collaborationUtils, + editor, + page, + } ) => { + await collaborationUtils.createCollaborativePost( { + title: 'Undo Test - Bystander', + } ); + + const { editor3, page2 } = collaborationUtils; + + // User B adds a block. + await collaborationUtils.insertBlockViaEvaluate( + page2, + 'core/paragraph', + { content: 'From User B' } + ); + + // Wait for User B's block to appear on User A. + await expect + .poll( () => editor.getBlocks(), { timeout: SYNC_TIMEOUT } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'From User B' }, + }, + ] ); + + // User A adds a block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'From User A' }, + } ); + + // Wait for both blocks to appear on the bystander (User C). + await collaborationUtils.assertEditorHasContent( editor3, [ + 'From User A', + 'From User B', + ] ); + + // User A undoes their own block. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).undo(); + } ); + + // Bystander (User C) should see only User B's block. + await collaborationUtils.assertEditorHasContent( + editor3, + [ 'From User B' ], + { not: [ 'From User A' ] } + ); + } ); +} ); diff --git a/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js b/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js new file mode 100644 index 0000000000000..9db8a8db23c49 --- /dev/null +++ b/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js @@ -0,0 +1,426 @@ +/** + * Collaboration E2E test utilities. + * + * Provides helpers for setting up multi-user collaborative editing + * sessions, managing browser contexts, and waiting for sync cycles. + * + * @package WordPress + * @since 7.0.0 + */ + +/** + * External dependencies + */ +import { expect } from '@playwright/test'; + +/** + * WordPress dependencies + */ +import { Editor } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Credentials for the second collaborator user. + * + * @since 7.0.0 + * @type {Object} + */ +export const SECOND_USER = { + username: 'collaborator', + email: 'collaborator@example.com', + firstName: 'Test', + lastName: 'Collaborator', + password: 'password', + roles: [ 'editor' ], +}; + +/** + * Credentials for the third collaborator user. + * + * @since 7.0.0 + * @type {Object} + */ +export const THIRD_USER = { + username: 'collaborator2', + email: 'collaborator2@example.com', + firstName: 'Another', + lastName: 'Collaborator', + password: 'password', + roles: [ 'editor' ], +}; + +const BASE_URL = process.env.WP_BASE_URL || 'http://localhost:8889'; + +/** + * Default timeout (ms) for sync-related assertions. + * + * @since 7.0.0 + * @type {number} + */ +export const SYNC_TIMEOUT = 10_000; + +/** + * Manages multi-user collaborative editing sessions for E2E tests. + * + * Handles browser context creation, user login, editor navigation, + * and sync-cycle waiting for up to three concurrent users. + * + * @since 7.0.0 + */ +export default class CollaborationUtils { + constructor( { admin, editor, requestUtils, page } ) { + this.admin = admin; + this.editor = editor; + this.requestUtils = requestUtils; + this.primaryPage = page; + + this._secondContext = null; + this._secondPage = null; + this._secondEditor = null; + + this._thirdContext = null; + this._thirdPage = null; + this._thirdEditor = null; + } + + /** + * Set the real-time collaboration WordPress setting. + * + * @param {boolean} enabled Whether to enable or disable collaboration. + */ + async setCollaboration( enabled ) { + await this.requestUtils.updateSiteSettings( { + wp_enable_real_time_collaboration: enabled, + } ); + } + + /** + * Log a user into WordPress via the login form on a given page. + * + * @param {import('@playwright/test').Page} page The page to log in on. + * @param {Object} userInfo User credentials. + */ + async loginUser( page, userInfo ) { + await page.goto( '/wp-login.php' ); + + // Retry filling if the page resets during a cold Docker start. + await expect( async () => { + await page.locator( '#user_login' ).fill( userInfo.username ); + await page.locator( '#user_pass' ).fill( userInfo.password ); + await expect( page.locator( '#user_pass' ) ).toHaveValue( + userInfo.password + ); + } ).toPass( { timeout: 15_000 } ); + + await page.getByRole( 'button', { name: 'Log In' } ).click(); + await page.waitForURL( '**/wp-admin/**' ); + } + + /** + * Set up a new browser context for a collaborator user. + * + * @param {Object} userInfo User credentials and info. + * @return {Object} An object with context, page, and editor. + */ + async setupCollaboratorContext( userInfo ) { + const context = await this.admin.browser.newContext( { + baseURL: BASE_URL, + } ); + const page = await context.newPage(); + + await this.loginUser( page, userInfo ); + + return { context, page }; + } + + /** + * Navigate a page to the post editor and dismiss the welcome guide. + * + * @param {import('@playwright/test').Page} page The page to navigate. + * @param {number} postId The post ID to edit. + */ + async navigateToEditor( page, postId ) { + await page.goto( + `/wp-admin/post.php?post=${ postId }&action=edit` + ); + await page.waitForFunction( + () => window?.wp?.data && window?.wp?.blocks + ); + await page.evaluate( () => { + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-post', 'welcomeGuide', false ); + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-post', 'fullscreenMode', false ); + } ); + } + + /** + * Open a collaborative editing session where all 3 users are editing + * the same post. + * + * @param {number} postId The post ID to collaboratively edit. + */ + async openCollaborativeSession( postId ) { + // Set up the second and third browser contexts. + const second = await this.setupCollaboratorContext( SECOND_USER ); + this._secondContext = second.context; + this._secondPage = second.page; + + const third = await this.setupCollaboratorContext( THIRD_USER ); + this._thirdContext = third.context; + this._thirdPage = third.page; + + // Navigate User 1 (admin) to the post editor. + await this.admin.visitAdminPage( + 'post.php', + `post=${ postId }&action=edit` + ); + await this.editor.setPreferences( 'core/edit-post', { + welcomeGuide: false, + fullscreenMode: false, + } ); + + // Wait for collaboration to be enabled on User 1's page. + await this.waitForCollaborationReady( this.primaryPage ); + + // Navigate User 2 and User 3 to the same post editor. + await this.navigateToEditor( this._secondPage, postId ); + await this.navigateToEditor( this._thirdPage, postId ); + + // Create Editor instances for the additional pages. + this._secondEditor = new Editor( { page: this._secondPage } ); + this._thirdEditor = new Editor( { page: this._thirdPage } ); + + // Wait for collaboration to be enabled on all pages. + await Promise.all( [ + this.waitForCollaborationReady( this._secondPage ), + this.waitForCollaborationReady( this._thirdPage ), + ] ); + + // Wait for all users to discover each other via awareness. + await Promise.all( [ + this.primaryPage + .getByRole( 'button', { name: /Collaborators list/ } ) + .waitFor( { timeout: 15000 } ), + this._secondPage + .getByRole( 'button', { name: /Collaborators list/ } ) + .waitFor( { timeout: 15000 } ), + this._thirdPage + .getByRole( 'button', { name: /Collaborators list/ } ) + .waitFor( { timeout: 15000 } ), + ] ); + + // Allow a full round of polling after awareness is established + // so all CRDT docs are synchronized. + await this.waitForAllSynced(); + } + + /** + * Wait for the collaboration runtime to be ready on a page. + * + * @param {import('@playwright/test').Page} page The Playwright page to wait on. + */ + async waitForCollaborationReady( page ) { + await page.waitForFunction( + () => + window._wpCollaborationEnabled === true && + window?.wp?.data && + window?.wp?.blocks, + { timeout: 15000 } + ); + } + + /** + * Wait for sync polling cycles to complete on the given page. + * + * @param {import('@playwright/test').Page} page The page to wait on. + * @param {number} cycles Number of sync responses to wait for. + */ + async waitForSyncCycle( page, cycles = 3 ) { + for ( let i = 0; i < cycles; i++ ) { + await page.waitForResponse( + ( response ) => + response.url().includes( 'wp-collaboration' ) && + response.status() === 200, + { timeout: SYNC_TIMEOUT } + ); + } + } + + /** + * Wait for sync cycles on all 3 pages in parallel. + * + * @param {number} cycles Number of sync responses to wait for per page. + */ + async waitForAllSynced( cycles = 3 ) { + const pages = [ this.primaryPage ]; + if ( this._secondPage ) { + pages.push( this._secondPage ); + } + if ( this._thirdPage ) { + pages.push( this._thirdPage ); + } + await Promise.all( + pages.map( ( page ) => this.waitForSyncCycle( page, cycles ) ) + ); + } + + /** + * Get the second user's Page instance. + */ + get page2() { + if ( ! this._secondPage ) { + throw new Error( + 'Second page not available. Call openCollaborativeSession() first.' + ); + } + return this._secondPage; + } + + /** + * Get the second user's Editor instance. + */ + get editor2() { + if ( ! this._secondEditor ) { + throw new Error( + 'Second editor not available. Call openCollaborativeSession() first.' + ); + } + return this._secondEditor; + } + + /** + * Get the third user's Page instance. + */ + get page3() { + if ( ! this._thirdPage ) { + throw new Error( + 'Third page not available. Call openCollaborativeSession() first.' + ); + } + return this._thirdPage; + } + + /** + * Get the third user's Editor instance. + */ + get editor3() { + if ( ! this._thirdEditor ) { + throw new Error( + 'Third editor not available. Call openCollaborativeSession() first.' + ); + } + return this._thirdEditor; + } + + /** + * Create a draft post and open a collaborative session on it. + * + * @since 7.0.0 + * + * @param {Object} options Options forwarded to `requestUtils.createPost()`. + * @return {Object} The created post object. + */ + async createCollaborativePost( options = {} ) { + const post = await this.requestUtils.createPost( { + status: 'draft', + date_gmt: new Date().toISOString(), + ...options, + } ); + await this.openCollaborativeSession( post.id ); + return post; + } + + /** + * Insert a block on a secondary page via `page.evaluate()`. + * + * @since 7.0.0 + * + * @param {import('@playwright/test').Page} page The page to insert on. + * @param {string} blockName Block name, e.g. 'core/paragraph'. + * @param {Object} attributes Block attributes. + */ + async insertBlockViaEvaluate( page, blockName, attributes ) { + await page.evaluate( + ( { name, attrs } ) => { + const block = window.wp.blocks.createBlock( name, attrs ); + window.wp.data + .dispatch( 'core/block-editor' ) + .insertBlock( block ); + }, + { name: blockName, attrs: attributes } + ); + } + + /** + * Assert that an editor contains (or does not contain) blocks with + * the given content strings. + * + * @since 7.0.0 + * + * @param {Editor} ed Editor instance to check. + * @param {string[]} expected Content strings that must be present. + * @param {Object} options + * @param {string[]} options.not Content strings that must NOT be present. + * @param {number} options.timeout Assertion timeout in ms. + */ + async assertEditorHasContent( + ed, + expected, + { not: notExpected = [], timeout = SYNC_TIMEOUT } = {} + ) { + await expect( async () => { + const blocks = await ed.getBlocks(); + const contents = blocks.map( ( b ) => b.attributes.content ); + for ( const item of expected ) { + expect( contents ).toContain( item ); + } + for ( const item of notExpected ) { + expect( contents ).not.toContain( item ); + } + } ).toPass( { timeout } ); + } + + /** + * Assert content across all open editors (primary + collaborators). + * + * @since 7.0.0 + * + * @param {string[]} expected Content strings that must be present. + * @param {Object} options Options forwarded to `assertEditorHasContent()`. + */ + async assertAllEditorsHaveContent( expected, options = {} ) { + const editors = [ this.editor ]; + if ( this._secondEditor ) { + editors.push( this._secondEditor ); + } + if ( this._thirdEditor ) { + editors.push( this._thirdEditor ); + } + for ( const ed of editors ) { + await this.assertEditorHasContent( ed, expected, options ); + } + } + + /** + * Clean up: close extra browser contexts, disable collaboration, + * delete test users. + */ + async teardown() { + if ( this._thirdContext ) { + await this._thirdContext.close(); + this._thirdContext = null; + this._thirdPage = null; + this._thirdEditor = null; + } + if ( this._secondContext ) { + await this._secondContext.close(); + this._secondContext = null; + this._secondPage = null; + this._secondEditor = null; + } + await this.setCollaboration( false ); + await this.requestUtils.deleteAllUsers(); + } +} diff --git a/tests/e2e/specs/collaboration/fixtures/index.js b/tests/e2e/specs/collaboration/fixtures/index.js new file mode 100644 index 0000000000000..446e6e88c459c --- /dev/null +++ b/tests/e2e/specs/collaboration/fixtures/index.js @@ -0,0 +1,48 @@ +/** + * Collaboration E2E test fixtures. + * + * Extends the base Playwright test with a `collaborationUtils` fixture + * that provisions three users and enables real-time collaboration. + * + * @package WordPress + * @since 7.0.0 + */ + +/** + * WordPress dependencies + */ +import { test as base } from '@wordpress/e2e-test-utils-playwright'; +export { expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import CollaborationUtils, { SECOND_USER, THIRD_USER, SYNC_TIMEOUT } from './collaboration-utils'; +export { SYNC_TIMEOUT }; + +export const test = base.extend( { + collaborationUtils: async ( + { admin, editor, requestUtils, page }, + use + ) => { + const utils = new CollaborationUtils( { + admin, + editor, + requestUtils, + page, + } ); + await utils.setCollaboration( true ); + await requestUtils.createUser( SECOND_USER ).catch( ( error ) => { + if ( error?.code !== 'existing_user_login' ) { + throw error; + } + } ); + await requestUtils.createUser( THIRD_USER ).catch( ( error ) => { + if ( error?.code !== 'existing_user_login' ) { + throw error; + } + } ); + await use( utils ); + await utils.teardown(); + }, +} ); diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php new file mode 100644 index 0000000000000..c4f95f0380efe --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -0,0 +1,1769 @@ +user->create( array( 'role' => 'editor' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$editor_id ); + self::delete_user( self::$subscriber_id ); + wp_delete_post( self::$post_id, true ); + } + + public function set_up() { + parent::set_up(); + + // Uses DELETE (not TRUNCATE) to preserve transaction rollback support + // in the test suite. TRUNCATE implicitly commits the transaction. + global $wpdb; + $wpdb->query( "DELETE FROM {$wpdb->collaboration}" ); + } + + /** + * Builds a room request array for the collaboration endpoint. + * + * @param string $room Room identifier. + * @param string $client_id Client ID. + * @param int $cursor Cursor value for the 'after' parameter. + * @param array $awareness Awareness state. + * @param array $updates Array of updates. + * @return array Room request data. + */ + private function build_room( $room, $client_id = '1', $cursor = 0, $awareness = array(), $updates = array() ) { + if ( empty( $awareness ) ) { + $awareness = array( 'user' => 'test' ); + } + + return array( + 'after' => $cursor, + 'awareness' => $awareness, + 'client_id' => $client_id, + 'room' => $room, + 'updates' => $updates, + ); + } + + /** + * Dispatches a collaboration request with the given rooms. + * + * @param array $rooms Array of room request data. + * @param string $_namespace REST namespace to use. Defaults to the primary namespace. + * @return WP_REST_Response Response object. + */ + private function dispatch_collaboration( $rooms, $_namespace = 'wp-collaboration/v1' ) { + $request = new WP_REST_Request( 'POST', '/' . $_namespace . '/updates' ); + $request->set_body_params( array( 'rooms' => $rooms ) ); + return rest_get_server()->dispatch( $request ); + } + + /** + * Returns the default room identifier for the test post. + * + * @return string Room identifier. + */ + private function get_post_room() { + return 'postType/post:' . self::$post_id; + } + + /* + * Required abstract method implementations. + * + * The collaboration endpoint is a single POST endpoint, not a standard CRUD controller. + * Methods that don't apply are stubbed with @doesNotPerformAssertions. + */ + + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp-collaboration/v1/updates', $routes ); + } + + /** + * Verifies the collaboration route is registered when relying on the option's default + * value (option not stored in the database). + * + * This covers the upgrade scenario where a site has never explicitly saved + * the collaboration setting. + * + * @ticket 64814 + */ + public function test_register_routes_with_default_option() { + global $wp_rest_server; + + // Ensure the option is not in the database. + delete_option( 'wp_enable_real_time_collaboration' ); + + // Reset the REST server so routes are re-registered from scratch. + $wp_rest_server = null; + + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp-collaboration/v1/updates', $routes ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // Not applicable for collaboration endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_items() { + // Not applicable for collaboration endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_item() { + // Not applicable for collaboration endpoint. + } + + public function test_create_item() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_update_item() { + // Not applicable for collaboration endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() { + // Not applicable for collaboration endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() { + // Not applicable for collaboration endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_item_schema() { + // Not applicable for collaboration endpoint. + } + + /* + * Permission tests. + */ + + public function test_collaboration_requires_authentication() { + wp_set_current_user( 0 ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); + } + + public function test_collaboration_post_requires_edit_capability() { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + public function test_collaboration_post_allowed_with_edit_capability() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + public function test_collaboration_post_type_collection_requires_edit_posts_capability() { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + public function test_collaboration_post_type_collection_allowed_with_edit_posts_capability() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + public function test_collaboration_root_collection_allowed() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'root/site' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + public function test_collaboration_taxonomy_collection_allowed() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'taxonomy/category' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + public function test_collaboration_unknown_collection_kind_rejected() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'unknown/entity' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + public function test_collaboration_non_posttype_entity_with_object_id_rejected() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'root/site:123' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + public function test_collaboration_nonexistent_post_rejected() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post:999999' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + public function test_collaboration_permission_checked_per_room() { + wp_set_current_user( self::$editor_id ); + + // First room is allowed, second room is forbidden. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $this->get_post_room() ), + $this->build_room( 'unknown/entity' ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /* + * Validation tests. + */ + + public function test_collaboration_invalid_room_format_rejected() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'invalid-room-format' ), + ) + ); + + $this->assertSame( 400, $response->get_status() ); + } + + /* + * Response format tests. + */ + + public function test_collaboration_response_structure() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'rooms', $data ); + $this->assertCount( 1, $data['rooms'] ); + + $room_data = $data['rooms'][0]; + $this->assertArrayHasKey( 'room', $room_data ); + $this->assertArrayHasKey( 'awareness', $room_data ); + $this->assertArrayHasKey( 'updates', $room_data ); + $this->assertArrayHasKey( 'end_cursor', $room_data ); + $this->assertArrayHasKey( 'total_updates', $room_data ); + $this->assertArrayHasKey( 'should_compact', $room_data ); + } + + public function test_collaboration_response_room_matches_request() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $response = $this->dispatch_collaboration( array( $this->build_room( $room ) ) ); + + $data = $response->get_data(); + $this->assertSame( $room, $data['rooms'][0]['room'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_end_cursor_is_non_negative_integer() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $data = $response->get_data(); + $this->assertIsInt( $data['rooms'][0]['end_cursor'] ); + // Cursor is 0 for an empty room (no rows in the table yet). + $this->assertGreaterThanOrEqual( 0, $data['rooms'][0]['end_cursor'] ); + } + + public function test_collaboration_empty_updates_returns_zero_total() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $data = $response->get_data(); + $this->assertSame( 0, $data['rooms'][0]['total_updates'] ); + $this->assertEmpty( $data['rooms'][0]['updates'] ); + } + + /* + * Update tests. + */ + + public function test_collaboration_update_delivered_to_other_client() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdCBkYXRh', + ); + + // Client 1 sends an update. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 requests updates from the beginning. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $data = $response->get_data(); + $updates = $data['rooms'][0]['updates']; + + $this->assertNotEmpty( $updates ); + + $types = wp_list_pluck( $updates, 'type' ); + $this->assertContains( 'update', $types ); + } + + public function test_collaboration_own_updates_not_returned() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'b3duIGRhdGE=', + ); + + // Client 1 sends an update. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + $data = $response->get_data(); + $updates = $data['rooms'][0]['updates']; + + // Client 1 should not see its own non-compaction update. + $this->assertEmpty( $updates ); + } + + public function test_collaboration_step1_update_stored_and_returned() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'sync_step1', + 'data' => 'c3RlcDE=', + ); + + // Client 1 sends sync_step1. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 should see the sync_step1 update. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $data = $response->get_data(); + $types = wp_list_pluck( $data['rooms'][0]['updates'], 'type' ); + $this->assertContains( 'sync_step1', $types ); + } + + public function test_collaboration_step2_update_stored_and_returned() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'sync_step2', + 'data' => 'c3RlcDI=', + ); + + // Client 1 sends sync_step2. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 should see the sync_step2 update. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $data = $response->get_data(); + $types = wp_list_pluck( $data['rooms'][0]['updates'], 'type' ); + $this->assertContains( 'sync_step2', $types ); + } + + public function test_collaboration_multiple_updates_in_single_request() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $updates = array( + array( + 'type' => 'sync_step1', + 'data' => 'c3RlcDE=', + ), + array( + 'type' => 'update', + 'data' => 'dXBkYXRl', + ), + ); + + // Client 1 sends multiple updates. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), $updates ), + ) + ); + + // Client 2 should see both updates. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $data = $response->get_data(); + $room_updates = $data['rooms'][0]['updates']; + + $this->assertCount( 2, $room_updates ); + $this->assertSame( 2, $data['rooms'][0]['total_updates'] ); + } + + public function test_collaboration_update_data_preserved() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'cHJlc2VydmVkIGRhdGE=', + ); + + // Client 1 sends an update. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 should receive the exact same data. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $data = $response->get_data(); + $room_updates = $data['rooms'][0]['updates']; + + $this->assertSame( 'cHJlc2VydmVkIGRhdGE=', $room_updates[0]['data'] ); + $this->assertSame( 'update', $room_updates[0]['type'] ); + } + + public function test_collaboration_total_updates_increments() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // Send three updates from different clients. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'user' => 'c2' ), array( $update ) ), + ) + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '3', 0, array( 'user' => 'c3' ), array( $update ) ), + ) + ); + + // Any client should see total_updates = 3. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '4', 0 ), + ) + ); + + $data = $response->get_data(); + $this->assertSame( 3, $data['rooms'][0]['total_updates'] ); + } + + /* + * Compaction tests. + */ + + public function test_collaboration_should_compact_is_false_below_threshold() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // Client 1 sends a single update. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + + $data = $response->get_data(); + $this->assertFalse( $data['rooms'][0]['should_compact'] ); + } + + public function test_collaboration_should_compact_is_true_above_threshold_for_compactor() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $updates = array(); + for ( $i = 0; $i < 51; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ); + } + + // Client 1 sends enough updates to exceed the compaction threshold. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), $updates ), + ) + ); + + // Client 1 polls again. It is the lowest (only) client, so it is the compactor. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertTrue( $data['rooms'][0]['should_compact'] ); + } + + /** + * Verifies that a caught-up compactor client still receives the + * should_compact signal when the room has accumulated updates + * beyond the compaction threshold. + * + * Regression test: the update count was previously cached as 0 + * when the cursor matched the latest update ID, preventing + * compaction from ever triggering for idle rooms. + * + * @ticket 64696 + */ + public function test_collaboration_should_compact_when_compactor_is_caught_up() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $updates = array(); + for ( $i = 0; $i < 51; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ); + } + + // Client 1 sends enough updates to exceed the compaction threshold. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), $updates ), + ) + ); + + // Grab the end_cursor so the client is fully caught up. + $data = $response->get_data(); + $end_cursor = $data['rooms'][0]['end_cursor']; + + // Client 1 polls again with cursor = end_cursor (caught up, no new updates). + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', $end_cursor, array( 'user' => 'c1' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertTrue( $data['rooms'][0]['should_compact'], 'Compactor should receive should_compact even when caught up.' ); + } + + public function test_collaboration_should_compact_is_false_for_non_compactor() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $updates = array(); + for ( $i = 0; $i < 51; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ); + } + + // Client 1 sends enough updates to exceed the compaction threshold. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), $updates ), + ) + ); + + // Client 2 (higher ID than client 1) should not be the compactor. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'user' => 'c2' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertFalse( $data['rooms'][0]['should_compact'] ); + } + + public function test_collaboration_stale_compaction_succeeds_when_newer_compaction_exists() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // Client 1 sends an update to seed the room. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + + $end_cursor = $response->get_data()['rooms'][0]['end_cursor']; + + // Client 2 sends a compaction at the current cursor. + $compaction = array( + 'type' => 'compaction', + 'data' => 'Y29tcGFjdGVk', + ); + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', $end_cursor, array( 'user' => 'c2' ), array( $compaction ) ), + ) + ); + + // Client 3 sends a stale compaction at cursor 0. The server should find + // client 2's compaction in the updates after cursor 0 and silently discard + // this one. + $stale_compaction = array( + 'type' => 'compaction', + 'data' => 'c3RhbGU=', + ); + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '3', 0, array( 'user' => 'c3' ), array( $stale_compaction ) ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + // Verify the newer compaction is preserved and the stale one was not stored. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '4', 0, array( 'user' => 'c4' ) ), + ) + ); + $update_data = wp_list_pluck( $response->get_data()['rooms'][0]['updates'], 'data' ); + + $this->assertContains( 'Y29tcGFjdGVk', $update_data, 'The newer compaction should be preserved.' ); + $this->assertNotContains( 'c3RhbGU=', $update_data, 'The stale compaction should not be stored.' ); + } + + /* + * Awareness tests. + */ + + public function test_collaboration_awareness_returned() { + wp_set_current_user( self::$editor_id ); + + $awareness = array( 'name' => 'Editor' ); + $response = $this->dispatch_collaboration( + array( + $this->build_room( $this->get_post_room(), '1', 0, $awareness ), + ) + ); + + $data = $response->get_data(); + $this->assertArrayHasKey( '1', $data['rooms'][0]['awareness'] ); + $this->assertSame( $awareness, $data['rooms'][0]['awareness'][1] ); + } + + public function test_collaboration_awareness_shows_multiple_clients() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 connects. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'name' => 'Client 1' ) ), + ) + ); + + // Client 2 connects. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'name' => 'Client 2' ) ), + ) + ); + + $data = $response->get_data(); + $awareness = $data['rooms'][0]['awareness']; + + $this->assertArrayHasKey( '1', $awareness ); + $this->assertArrayHasKey( '2', $awareness ); + $this->assertSame( array( 'name' => 'Client 1' ), $awareness['1'] ); + $this->assertSame( array( 'name' => 'Client 2' ), $awareness['2'] ); + } + + public function test_collaboration_awareness_updates_existing_client() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 connects with initial awareness. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'start' ) ), + ) + ); + + // Client 1 updates its awareness. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'updated' ) ), + ) + ); + + $data = $response->get_data(); + $awareness = $data['rooms'][0]['awareness']; + + // Should have exactly one entry for client 1 with updated state. + $this->assertCount( 1, $awareness ); + $this->assertSame( array( 'cursor' => 'updated' ), $awareness['1'] ); + } + + public function test_collaboration_awareness_client_id_cannot_be_used_by_another_user() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Editor establishes awareness with client_id 1. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'name' => 'Editor' ) ), + ) + ); + + // A different user tries to use the same client_id. + $editor_id_2 = self::factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $editor_id_2 ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'name' => 'Impostor' ) ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /* + * Multiple rooms tests. + */ + + public function test_collaboration_multiple_rooms_in_single_request() { + wp_set_current_user( self::$editor_id ); + + $room1 = $this->get_post_room(); + $room2 = 'taxonomy/category'; + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room1 ), + $this->build_room( $room2 ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 2, $data['rooms'] ); + $this->assertSame( $room1, $data['rooms'][0]['room'] ); + $this->assertSame( $room2, $data['rooms'][1]['room'] ); + } + + public function test_collaboration_rooms_are_isolated() { + wp_set_current_user( self::$editor_id ); + + $post_id_2 = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $room1 = $this->get_post_room(); + $room2 = 'postType/post:' . $post_id_2; + + $update = array( + 'type' => 'update', + 'data' => 'cm9vbTEgb25seQ==', + ); + + // Client 1 sends an update to room 1 only. + $this->dispatch_collaboration( + array( + $this->build_room( $room1, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 queries both rooms. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room1, '2', 0 ), + $this->build_room( $room2, '2', 0 ), + ) + ); + + $data = $response->get_data(); + + // Room 1 should have the update. + $this->assertNotEmpty( $data['rooms'][0]['updates'] ); + + // Room 2 should have no updates. + $this->assertEmpty( $data['rooms'][1]['updates'] ); + } + + /* + * Cursor tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_empty_room_cursor_is_zero(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $data = $response->get_data(); + $this->assertSame( 0, $data['rooms'][0]['end_cursor'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_cursor_advances_monotonically(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // First request. + $response1 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + $cursor1 = $response1->get_data()['rooms'][0]['end_cursor']; + + // Second request with more updates. + $response2 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', $cursor1, array( 'user' => 'c2' ), array( $update ) ), + ) + ); + $cursor2 = $response2->get_data()['rooms'][0]['end_cursor']; + + $this->assertGreaterThan( $cursor1, $cursor2, 'Cursor should advance monotonically with new updates.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_cursor_prevents_re_delivery(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => base64_encode( 'first-batch' ), + ); + + // Client 1 sends an update. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + + // Client 2 fetches updates and gets a cursor. + $response1 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'user' => 'c2' ) ), + ) + ); + $data1 = $response1->get_data(); + $cursor1 = $data1['rooms'][0]['end_cursor']; + + $this->assertNotEmpty( $data1['rooms'][0]['updates'], 'First poll should return updates.' ); + + // Client 2 polls again using the cursor from the first poll, with no new updates. + $response2 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', $cursor1, array( 'user' => 'c2' ) ), + ) + ); + $data2 = $response2->get_data(); + + $this->assertEmpty( $data2['rooms'][0]['updates'], 'Second poll with cursor should not re-deliver updates.' ); + } + + /* + * Cache thrashing tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_operations_do_not_affect_posts_last_changed(): void { + wp_set_current_user( self::$editor_id ); + + // Prime the posts last changed cache. + wp_cache_set_posts_last_changed(); + $last_changed_before = wp_cache_get_last_changed( 'posts' ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // Perform several collaboration operations. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'user' => 'c2' ), array( $update ) ), + ) + ); + + $last_changed_after = wp_cache_get_last_changed( 'posts' ); + + $this->assertSame( $last_changed_before, $last_changed_after, 'Collaboration operations should not invalidate the posts last changed cache.' ); + } + + /* + * Race condition tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_compaction_does_not_lose_concurrent_updates(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sends an initial batch of updates. + $initial_updates = array(); + for ( $i = 0; $i < 5; $i++ ) { + $initial_updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "initial-$i" ), + ); + } + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), $initial_updates ), + ) + ); + + $data = $response->get_data(); + $cursor = $data['rooms'][0]['end_cursor']; + + // Client 2 sends a new update (simulating a concurrent write). + $concurrent_update = array( + 'type' => 'update', + 'data' => base64_encode( 'concurrent' ), + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'user' => 'c2' ), array( $concurrent_update ) ), + ) + ); + + // Client 1 sends a compaction update using its cursor. + $compaction_update = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted-state' ), + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', $cursor, array( 'user' => 'c1' ), array( $compaction_update ) ), + ) + ); + + // Client 3 requests all updates from the beginning. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '3', 0, array( 'user' => 'c3' ) ), + ) + ); + + $data = $response->get_data(); + $room_updates = $data['rooms'][0]['updates']; + $update_data = wp_list_pluck( $room_updates, 'data' ); + + // The concurrent update must not be lost. + $this->assertContains( base64_encode( 'concurrent' ), $update_data, 'Concurrent update should not be lost during compaction.' ); + + // The compaction update should be present. + $this->assertContains( base64_encode( 'compacted-state' ), $update_data, 'Compaction update should be present.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_compaction_reduces_total_updates(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $updates = array(); + for ( $i = 0; $i < 10; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ); + } + + // Client 1 sends 10 updates. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), $updates ), + ) + ); + + $data = $response->get_data(); + $cursor = $data['rooms'][0]['end_cursor']; + + // Client 1 sends a compaction to replace the 10 updates. + $compaction = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted' ), + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', $cursor, array( 'user' => 'c1' ), array( $compaction ) ), + ) + ); + + // Client 2 checks the state. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'user' => 'c2' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertLessThan( 10, $data['rooms'][0]['total_updates'], 'Compaction should reduce the total update count.' ); + } + + /* + * Cron cleanup tests. + */ + + /** + * Inserts a row directly into the collaboration table with a given age. + * + * @param positive-int $age_in_seconds How old the row should be. + * @param string $label A label stored in the update_value for identification. + */ + private function insert_collaboration_row( int $age_in_seconds, string $label = 'test' ): void { + global $wpdb; + + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $this->get_post_room(), + 'type' => 'update', + 'client_id' => '1', + 'update_value' => wp_json_encode( + array( + 'type' => 'update', + 'data' => $label, + ) + ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), + ), + array( '%s', '%s', '%s', '%s', '%s' ) + ); + } + + /** + * Returns the number of non-awareness rows in the collaboration table. + * + * @return positive-int Row count. + */ + private function get_collaboration_row_count(): int { + global $wpdb; + + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE type != 'awareness'" ); + } + + /** + * Returns the number of awareness rows in the collaboration table. + * + * @return positive-int Row count. + */ + private function get_awareness_row_count(): int { + global $wpdb; + + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE type = 'awareness'" ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_deletes_old_rows(): void { + $this->insert_collaboration_row( 8 * DAY_IN_SECONDS ); + + $this->assertSame( 1, $this->get_collaboration_row_count() ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 0, $this->get_collaboration_row_count() ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_preserves_recent_rows(): void { + $this->insert_collaboration_row( DAY_IN_SECONDS ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 1, $this->get_collaboration_row_count() ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_boundary_at_exactly_seven_days(): void { + $this->insert_collaboration_row( WEEK_IN_SECONDS + 1, 'expired' ); + $this->insert_collaboration_row( WEEK_IN_SECONDS - 1, 'just-inside' ); + + wp_delete_old_collaboration_data(); + + global $wpdb; + $remaining = $wpdb->get_col( "SELECT update_value FROM {$wpdb->collaboration}" ); + + $this->assertCount( 1, $remaining, 'Only the row within the 7-day window should remain.' ); + $this->assertStringContainsString( 'just-inside', $remaining[0], 'The surviving row should be the one inside the window.' ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_selectively_deletes_mixed_rows(): void { + // 3 expired rows. + $this->insert_collaboration_row( 10 * DAY_IN_SECONDS ); + $this->insert_collaboration_row( 10 * DAY_IN_SECONDS ); + $this->insert_collaboration_row( 10 * DAY_IN_SECONDS ); + + // 2 recent rows. + $this->insert_collaboration_row( HOUR_IN_SECONDS ); + $this->insert_collaboration_row( HOUR_IN_SECONDS ); + + $this->assertSame( 5, $this->get_collaboration_row_count() ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 2, $this->get_collaboration_row_count(), 'Only the 2 recent rows should survive cleanup.' ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_hook_is_registered(): void { + $this->assertSame( + 10, + has_action( 'wp_delete_old_collaboration_data', 'wp_delete_old_collaboration_data' ), + 'The wp_delete_old_collaboration_data action should be hooked in default-filters.php.' + ); + } + + /* + * Route registration guard tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_routes_not_registered_when_db_version_is_old(): void { + update_option( 'db_version', 61839 ); + + // Reset the global REST server so rest_get_server() builds a fresh instance. + $GLOBALS['wp_rest_server'] = null; + + $server = rest_get_server(); + $routes = $server->get_routes(); + + $this->assertArrayNotHasKey( '/wp-collaboration/v1/updates', $routes, 'Collaboration routes should not be registered when db_version is below 61840.' ); + + // Reset again so subsequent tests get a server with the correct db_version. + $GLOBALS['wp_rest_server'] = null; + } + + /* + * Awareness race condition tests. + */ + + /** + * Awareness state set by separate clients should be preserved across sequential dispatches. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_preserved_across_separate_upserts(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sets awareness. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + // Client 2 sets awareness (simulating a concurrent request). + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'cursor' => 'pos-b' ) ), + ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + + $this->assertArrayHasKey( '1', $awareness, 'Client 1 awareness should be present.' ); + $this->assertArrayHasKey( '2', $awareness, 'Client 2 awareness should be present.' ); + $this->assertSame( array( 'cursor' => 'pos-a' ), $awareness['1'] ); + $this->assertSame( array( 'cursor' => 'pos-b' ), $awareness['2'] ); + } + + /** + * Awareness rows should not affect get_updates_after_cursor() or get_cursor(). + * + * @ticket 64696 + */ + public function test_collaboration_awareness_rows_do_not_affect_cursor(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sets awareness (creates awareness row in table). + $response1 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + // With no updates, cursor should be 0. + $data1 = $response1->get_data(); + $this->assertSame( 0, $data1['rooms'][0]['end_cursor'], 'Awareness rows should not affect the cursor.' ); + $this->assertSame( 0, $data1['rooms'][0]['total_updates'], 'Awareness rows should not count as updates.' ); + $this->assertEmpty( $data1['rooms'][0]['updates'], 'Awareness rows should not appear as updates.' ); + + // Now add an update. + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + $response2 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ), array( $update ) ), + ) + ); + + $data2 = $response2->get_data(); + $this->assertSame( 1, $data2['rooms'][0]['total_updates'], 'Only updates should count toward total.' ); + } + + /** + * Compaction (remove_updates_before_cursor) should not delete awareness rows. + * + * @ticket 64696 + */ + public function test_collaboration_compaction_does_not_delete_awareness_rows(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sets awareness. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + // Client 2 sends updates. + $updates = array(); + for ( $i = 0; $i < 5; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ); + } + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'cursor' => 'pos-b' ), $updates ), + ) + ); + + $cursor = $response->get_data()['rooms'][0]['end_cursor']; + + // Client 2 sends a compaction. + $compaction = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted' ), + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', $cursor, array( 'cursor' => 'pos-b' ), array( $compaction ) ), + ) + ); + + // Client 3 checks awareness — client 1 should still be present. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '3', 0, array( 'cursor' => 'pos-c' ) ), + ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertArrayHasKey( '1', $awareness, 'Client 1 awareness should survive compaction.' ); + } + + /** + * Expired awareness rows should be filtered from results but remain in the + * table until cron cleanup runs. + * + * @ticket 64696 + */ + public function test_collaboration_expired_awareness_rows_cleaned_up(): void { + wp_set_current_user( self::$editor_id ); + + global $wpdb; + + $room = $this->get_post_room(); + + // Insert an awareness row clearly older than the 60-second cron threshold. + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => 'awareness', + 'client_id' => '99', + 'user_id' => self::$editor_id, + 'update_value' => wp_json_encode( array( 'cursor' => 'stale' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + // Client 1 polls — the expired row should not appear in results. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertArrayNotHasKey( '99', $awareness, 'Expired awareness entry should not appear.' ); + $this->assertArrayHasKey( '1', $awareness, 'Fresh client awareness should appear.' ); + + // The expired row still exists in the table (no inline DELETE on the read path). + $expired_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE type = 'awareness' AND room = %s AND client_id = %s", + $room, + '99' + ) + ); + $this->assertSame( 1, $expired_count, 'Expired awareness row should still exist in the table until cron runs.' ); + + // Cron cleanup removes the expired row. + wp_delete_old_collaboration_data(); + + $post_cron_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE type = 'awareness' AND room = %s AND client_id = %s", + $room, + '99' + ) + ); + $this->assertSame( 0, $post_cron_count, 'Expired awareness row should be deleted after cron cleanup.' ); + } + + /** + * Cron cleanup should remove expired awareness rows. + * + * @ticket 64696 + */ + public function test_cron_cleanup_deletes_expired_awareness_rows(): void { + global $wpdb; + + // Insert an awareness row older than 60 seconds. + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $this->get_post_room(), + 'type' => 'awareness', + 'client_id' => '42', + 'user_id' => self::$editor_id, + 'update_value' => wp_json_encode( array( 'cursor' => 'old' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + // Insert a recent collaboration row (should survive). + $this->insert_collaboration_row( HOUR_IN_SECONDS ); + + $this->assertSame( 1, $this->get_collaboration_row_count(), 'Collaboration table should have 1 sync row.' ); + $this->assertSame( 1, $this->get_awareness_row_count(), 'Collaboration table should have 1 awareness row.' ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 1, $this->get_collaboration_row_count(), 'Only the recent sync row should survive cron cleanup.' ); + $this->assertSame( 0, $this->get_awareness_row_count(), 'Expired awareness row should be deleted after cron cleanup.' ); + } + + /** + * Verifies that user_id is stored as a dedicated column, + * not embedded inside the update_value JSON blob. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_user_id_round_trip() { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $rooms = array( $this->build_room( $room, '1', 0, array( 'cursor' => array( 'x' => 10 ) ) ) ); + + $response = $this->dispatch_collaboration( $rooms ); + $this->assertSame( 200, $response->get_status(), 'Dispatch should succeed.' ); + + // Query the collaboration table directly for the awareness row. + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT user_id, update_value FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s", + $room, + '1' + ) + ); + + $this->assertNotNull( $row, 'Awareness row should exist.' ); + $this->assertSame( self::$editor_id, (int) $row->user_id, 'user_id column should match the editor.' ); + $this->assertStringNotContainsString( 'user_id', $row->update_value, 'update_value should not contain user_id.' ); + } + + /** + * Verifies that the is_array() guard in get_awareness_state() skips + * rows where update_value contains valid JSON that is not an array. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_non_array_json_ignored() { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Insert a malformed awareness row with a JSON string (not an array). + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => 'awareness', + 'client_id' => '99', + 'user_id' => self::$editor_id, + 'update_value' => '"hello"', + 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + // Dispatch as a different client so the response includes other clients' awareness. + $rooms = array( $this->build_room( $room, '2', 0, array( 'cursor' => 'here' ) ) ); + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + + $awareness = $data['rooms'][0]['awareness']; + + $this->assertArrayNotHasKey( '99', $awareness, 'Non-array JSON row should not appear in awareness.' ); + $this->assertArrayHasKey( '2', $awareness, 'The dispatching client should appear in awareness.' ); + } + + /** + * Validates that REST accepts room names at the column width boundary (191 chars). + * + * @ticket 64696 + */ + public function test_collaboration_room_name_at_max_length_accepted() { + wp_set_current_user( self::$editor_id ); + + // 191 characters using a collection room: 'root/' (5) + 186 chars. + $room = 'root/' . str_repeat( 'a', 186 ); + $this->assertSame( 191, strlen( $room ), 'Room name should be 191 characters.' ); + + $rooms = array( $this->build_room( $room ) ); + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 200, $response->get_status(), 'REST should accept room names at 191 characters.' ); + } + + /** + * Validates that REST rejects room names exceeding the column width (191 chars). + * + * @ticket 64696 + */ + public function test_collaboration_room_name_max_length_rejected() { + wp_set_current_user( self::$editor_id ); + + // 192 characters: 'postType/' (9) + 183 chars. + $long_room = 'postType/' . str_repeat( 'a', 183 ); + $this->assertSame( 192, strlen( $long_room ), 'Room name should be 192 characters.' ); + + $rooms = array( $this->build_room( $long_room ) ); + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 400, $response->get_status(), 'REST should reject room names exceeding 191 characters.' ); + } + + /** + * Verifies that sending awareness as null reads existing state without writing. + * + * @ticket 64696 + */ + public function test_collaboration_null_awareness_skips_write() { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 dispatches with awareness state (writes a row). + $rooms = array( $this->build_room( $room, '1', 0, array( 'cursor' => 'active' ) ) ); + $this->dispatch_collaboration( $rooms ); + + // Client 2 dispatches with awareness = null (should not write). + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => null, + 'client_id' => '2', + 'room' => $room, + 'updates' => array(), + ), + ), + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status(), 'Null awareness dispatch should succeed.' ); + + // Assert collaboration table has exactly 1 awareness row (client 1 only). + $row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE type = 'awareness'" ); + $this->assertSame( 1, $row_count, 'Only client 1 should have an awareness row.' ); + + // Assert response still contains client 1's awareness (read still works). + $data = $response->get_data(); + $awareness = $data['rooms'][0]['awareness']; + $this->assertArrayHasKey( '1', $awareness, 'Client 1 awareness should be readable by client 2.' ); + $this->assertSame( array( 'cursor' => 'active' ), $awareness['1'], 'Client 1 awareness state should match.' ); + } + + /* + * Query count tests. + */ + + /* + * Deprecated route tests. + */ + + /** + * Verifies the deprecated wp-sync/v1 route alias works identically to + * the canonical wp-collaboration/v1 namespace. + * + * @ticket 64696 + */ + public function test_collaboration_deprecated_sync_route() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'c3luYyByb3V0ZQ==', + ); + + // Send an update via the deprecated namespace. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ), + 'wp-sync/v1' + ); + + $this->assertSame( 200, $response->get_status(), 'Deprecated wp-sync/v1 route should return 200.' ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'rooms', $data, 'Response should contain rooms key.' ); + $this->assertSame( $room, $data['rooms'][0]['room'], 'Room identifier should match.' ); + + // Verify the update is retrievable via the canonical namespace. + $response2 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $updates = $response2->get_data()['rooms'][0]['updates']; + $this->assertNotEmpty( $updates, 'Update sent via deprecated route should be retrievable via canonical route.' ); + + $update_data = wp_list_pluck( $updates, 'data' ); + $this->assertContains( 'c3luYyByb3V0ZQ==', $update_data ); + } + + /** + * An idle poll (no new updates) should use at most 4 queries per room: + * 1. SELECT … FROM collaboration WHERE type = 'awareness' (read + ownership check) + * 2. UPDATE … collaboration (awareness upsert — update path) + * 3. SELECT MAX(id), COUNT(*) FROM collaboration (snapshot + count) + * 4. INSERT … collaboration (awareness upsert — insert path, only on first poll) + * + * @ticket 64696 + */ + public function test_collaboration_idle_poll_query_count(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Prime awareness so subsequent polls are idle heartbeats. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'test' ) ), + ) + ); + + $cursor = 0; + + // Count queries for an idle poll (no updates to fetch). + $queries_before = $wpdb->num_queries; + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', $cursor, array( 'user' => 'test' ) ), + ) + ); + + $this->assertSame( 200, $response->get_status(), 'Idle poll should succeed.' ); + + $query_count = $wpdb->num_queries - $queries_before; + + $this->assertLessThanOrEqual( + 4, + $query_count, + sprintf( 'Idle poll should use at most 4 queries per room, used %d.', $query_count ) + ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 89bf2c481c567..3b7a8c99e4e97 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -16,8 +16,8 @@ class WP_Test_REST_Schema_Initialization extends WP_Test_REST_TestCase { public function set_up() { parent::set_up(); - // Ensure client-side media processing is enabled so the sideload route is registered. - add_filter( 'wp_client_side_media_processing_enabled', '__return_true' ); + // Ensure collaboration routes are registered. + add_filter( 'pre_option_wp_enable_real_time_collaboration', '__return_true' ); /** @var WP_REST_Server $wp_rest_server */ global $wp_rest_server; @@ -113,7 +113,6 @@ public function test_expected_routes_in_schema() { '/wp/v2/media/(?P[\\d]+)/post-process', '/wp/v2/media/(?P[\\d]+)/edit', '/wp/v2/media/(?P[\\d]+)/sideload', - '/wp/v2/media/(?P[\\d]+)/finalize', '/wp/v2/blocks', '/wp/v2/blocks/(?P[\d]+)', '/wp/v2/blocks/(?P[\d]+)/autosaves', @@ -208,6 +207,10 @@ public function test_expected_routes_in_schema() { '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+?)/run', '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+)', '/wp-abilities/v1/abilities', + '/wp-collaboration/v1', + '/wp-collaboration/v1/updates', + '/wp-sync/v1', + '/wp-sync/v1/updates', ); $this->assertSameSets( $expected_routes, $routes ); @@ -219,7 +222,9 @@ private function is_builtin_route( $route ) { preg_match( '#^/oembed/1\.0(/.+)?$#', $route ) || preg_match( '#^/wp/v2(/.+)?$#', $route ) || preg_match( '#^/wp-site-health/v1(/.+)?$#', $route ) || - preg_match( '#^/wp-abilities/v1(/.+)?$#', $route ) + preg_match( '#^/wp-abilities/v1(/.+)?$#', $route ) || + preg_match( '#^/wp-collaboration/v1(/.+)?$#', $route ) || + preg_match( '#^/wp-sync/v1(/.+)?$#', $route ) ); } diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php deleted file mode 100644 index 3f82a50b35f81..0000000000000 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ /dev/null @@ -1,867 +0,0 @@ -user->create( array( 'role' => 'editor' ) ); - self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); - self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); - } - - public static function wpTearDownAfterClass() { - self::delete_user( self::$editor_id ); - self::delete_user( self::$subscriber_id ); - wp_delete_post( self::$post_id, true ); - } - - public function set_up() { - parent::set_up(); - - // Enable option for tests. - add_filter( 'pre_option_wp_enable_real_time_collaboration', '__return_true' ); - - // Reset storage post ID cache to ensure clean state after transaction rollback. - $reflection = new ReflectionProperty( 'WP_Sync_Post_Meta_Storage', 'storage_post_ids' ); - if ( PHP_VERSION_ID < 80100 ) { - $reflection->setAccessible( true ); - } - $reflection->setValue( null, array() ); - } - - /** - * Builds a room request array for the sync endpoint. - * - * @param string $room Room identifier. - * @param int $client_id Client ID. - * @param int $cursor Cursor value for the 'after' parameter. - * @param array $awareness Awareness state. - * @param array $updates Array of updates. - * @return array Room request data. - */ - private function build_room( $room, $client_id = 1, $cursor = 0, $awareness = array(), $updates = array() ) { - if ( empty( $awareness ) ) { - $awareness = array( 'user' => 'test' ); - } - - return array( - 'after' => $cursor, - 'awareness' => $awareness, - 'client_id' => $client_id, - 'room' => $room, - 'updates' => $updates, - ); - } - - /** - * Dispatches a sync request with the given rooms. - * - * @param array $rooms Array of room request data. - * @return WP_REST_Response Response object. - */ - private function dispatch_sync( $rooms ) { - $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); - $request->set_body_params( array( 'rooms' => $rooms ) ); - return rest_get_server()->dispatch( $request ); - } - - /** - * Returns the default room identifier for the test post. - * - * @return string Room identifier. - */ - private function get_post_room() { - return 'postType/post:' . self::$post_id; - } - - /* - * Required abstract method implementations. - * - * The sync endpoint is a single POST endpoint, not a standard CRUD controller. - * Methods that don't apply are stubbed with @doesNotPerformAssertions. - */ - - public function test_register_routes() { - $routes = rest_get_server()->get_routes(); - $this->assertArrayHasKey( '/wp-sync/v1/updates', $routes ); - } - - /** - * Verifies the sync route is registered when relying on the option's default - * value (option not stored in the database). - * - * This covers the upgrade scenario where a site has never explicitly saved - * the collaboration setting. - * - * @ticket 64814 - */ - public function test_register_routes_with_default_option() { - global $wp_rest_server; - - // Remove the pre_option filter added in ::set_up() so get_option() uses its default logic. - remove_filter( 'pre_option_wp_enable_real_time_collaboration', '__return_true' ); - - // Ensure the option is not in the database. - delete_option( 'wp_enable_real_time_collaboration' ); - - // Reset the REST server so routes are re-registered from scratch. - $wp_rest_server = null; - - $routes = rest_get_server()->get_routes(); - $this->assertArrayHasKey( '/wp-sync/v1/updates', $routes ); - } - - /** - * @doesNotPerformAssertions - */ - public function test_context_param() { - // Not applicable for sync endpoint. - } - - /** - * @doesNotPerformAssertions - */ - public function test_get_items() { - // Not applicable for sync endpoint. - } - - /** - * @doesNotPerformAssertions - */ - public function test_get_item() { - // Not applicable for sync endpoint. - } - - public function test_create_item() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $this->assertSame( 200, $response->get_status() ); - } - - /** - * @doesNotPerformAssertions - */ - public function test_update_item() { - // Not applicable for sync endpoint. - } - - /** - * @doesNotPerformAssertions - */ - public function test_delete_item() { - // Not applicable for sync endpoint. - } - - /** - * @doesNotPerformAssertions - */ - public function test_prepare_item() { - // Not applicable for sync endpoint. - } - - /** - * @doesNotPerformAssertions - */ - public function test_get_item_schema() { - // Not applicable for sync endpoint. - } - - /* - * Permission tests. - */ - - public function test_sync_requires_authentication() { - wp_set_current_user( 0 ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); - } - - public function test_sync_post_requires_edit_capability() { - wp_set_current_user( self::$subscriber_id ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - public function test_sync_post_allowed_with_edit_capability() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $this->assertSame( 200, $response->get_status() ); - } - - public function test_sync_post_type_collection_requires_edit_posts_capability() { - wp_set_current_user( self::$subscriber_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'postType/post' ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - public function test_sync_post_type_collection_allowed_with_edit_posts_capability() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'postType/post' ) ) ); - - $this->assertSame( 200, $response->get_status() ); - } - - public function test_sync_root_collection_allowed() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'root/site' ) ) ); - - $this->assertSame( 200, $response->get_status() ); - } - - public function test_sync_taxonomy_collection_allowed() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category' ) ) ); - - $this->assertSame( 200, $response->get_status() ); - } - - public function test_sync_unknown_collection_kind_rejected() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'unknown/entity' ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - public function test_sync_non_posttype_entity_with_object_id_rejected() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'root/site:123' ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - public function test_sync_nonexistent_post_rejected() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:999999' ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - public function test_sync_permission_checked_per_room() { - wp_set_current_user( self::$editor_id ); - - // First room is allowed, second room is forbidden. - $response = $this->dispatch_sync( - array( - $this->build_room( $this->get_post_room() ), - $this->build_room( 'unknown/entity' ), - ) - ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - /* - * Validation tests. - */ - - public function test_sync_invalid_room_format_rejected() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( - array( - $this->build_room( 'invalid-room-format' ), - ) - ); - - $this->assertSame( 400, $response->get_status() ); - } - - /* - * Response format tests. - */ - - public function test_sync_response_structure() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - $this->assertArrayHasKey( 'rooms', $data ); - $this->assertCount( 1, $data['rooms'] ); - - $room_data = $data['rooms'][0]; - $this->assertArrayHasKey( 'room', $room_data ); - $this->assertArrayHasKey( 'awareness', $room_data ); - $this->assertArrayHasKey( 'updates', $room_data ); - $this->assertArrayHasKey( 'end_cursor', $room_data ); - $this->assertArrayHasKey( 'total_updates', $room_data ); - $this->assertArrayHasKey( 'should_compact', $room_data ); - } - - public function test_sync_response_room_matches_request() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $response = $this->dispatch_sync( array( $this->build_room( $room ) ) ); - - $data = $response->get_data(); - $this->assertSame( $room, $data['rooms'][0]['room'] ); - } - - public function test_sync_end_cursor_is_positive_integer() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $data = $response->get_data(); - $this->assertIsInt( $data['rooms'][0]['end_cursor'] ); - $this->assertGreaterThan( 0, $data['rooms'][0]['end_cursor'] ); - } - - public function test_sync_empty_updates_returns_zero_total() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - - $data = $response->get_data(); - $this->assertSame( 0, $data['rooms'][0]['total_updates'] ); - $this->assertEmpty( $data['rooms'][0]['updates'] ); - } - - /* - * Update tests. - */ - - public function test_sync_update_delivered_to_other_client() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'dGVzdCBkYXRh', - ); - - // Client 1 sends an update. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), - ) - ); - - // Client 2 requests updates from the beginning. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0 ), - ) - ); - - $data = $response->get_data(); - $updates = $data['rooms'][0]['updates']; - - $this->assertNotEmpty( $updates ); - - $types = wp_list_pluck( $updates, 'type' ); - $this->assertContains( 'update', $types ); - } - - public function test_sync_own_updates_not_returned() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'b3duIGRhdGE=', - ); - - // Client 1 sends an update. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), - ) - ); - - $data = $response->get_data(); - $updates = $data['rooms'][0]['updates']; - - // Client 1 should not see its own non-compaction update. - $this->assertEmpty( $updates ); - } - - public function test_sync_step1_update_stored_and_returned() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'sync_step1', - 'data' => 'c3RlcDE=', - ); - - // Client 1 sends sync_step1. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), - ) - ); - - // Client 2 should see the sync_step1 update. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0 ), - ) - ); - - $data = $response->get_data(); - $types = wp_list_pluck( $data['rooms'][0]['updates'], 'type' ); - $this->assertContains( 'sync_step1', $types ); - } - - public function test_sync_step2_update_stored_and_returned() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'sync_step2', - 'data' => 'c3RlcDI=', - ); - - // Client 1 sends sync_step2. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), - ) - ); - - // Client 2 should see the sync_step2 update. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0 ), - ) - ); - - $data = $response->get_data(); - $types = wp_list_pluck( $data['rooms'][0]['updates'], 'type' ); - $this->assertContains( 'sync_step2', $types ); - } - - public function test_sync_multiple_updates_in_single_request() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $updates = array( - array( - 'type' => 'sync_step1', - 'data' => 'c3RlcDE=', - ), - array( - 'type' => 'update', - 'data' => 'dXBkYXRl', - ), - ); - - // Client 1 sends multiple updates. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), $updates ), - ) - ); - - // Client 2 should see both updates. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0 ), - ) - ); - - $data = $response->get_data(); - $room_updates = $data['rooms'][0]['updates']; - - $this->assertCount( 2, $room_updates ); - $this->assertSame( 2, $data['rooms'][0]['total_updates'] ); - } - - public function test_sync_update_data_preserved() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'cHJlc2VydmVkIGRhdGE=', - ); - - // Client 1 sends an update. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), - ) - ); - - // Client 2 should receive the exact same data. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0 ), - ) - ); - - $data = $response->get_data(); - $room_updates = $data['rooms'][0]['updates']; - - $this->assertSame( 'cHJlc2VydmVkIGRhdGE=', $room_updates[0]['data'] ); - $this->assertSame( 'update', $room_updates[0]['type'] ); - } - - public function test_sync_total_updates_increments() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'dGVzdA==', - ); - - // Send three updates from different clients. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), - ) - ); - $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0, array( 'user' => 'c2' ), array( $update ) ), - ) - ); - $this->dispatch_sync( - array( - $this->build_room( $room, 3, 0, array( 'user' => 'c3' ), array( $update ) ), - ) - ); - - // Any client should see total_updates = 3. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 4, 0 ), - ) - ); - - $data = $response->get_data(); - $this->assertSame( 3, $data['rooms'][0]['total_updates'] ); - } - - /* - * Compaction tests. - */ - - public function test_sync_should_compact_is_false_below_threshold() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'dGVzdA==', - ); - - // Client 1 sends a single update. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), - ) - ); - - $data = $response->get_data(); - $this->assertFalse( $data['rooms'][0]['should_compact'] ); - } - - public function test_sync_should_compact_is_true_above_threshold_for_compactor() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $updates = array(); - for ( $i = 0; $i < 51; $i++ ) { - $updates[] = array( - 'type' => 'update', - 'data' => base64_encode( "update-$i" ), - ); - } - - // Client 1 sends enough updates to exceed the compaction threshold. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $updates ), - ) - ); - - // Client 1 polls again. It is the lowest (only) client, so it is the compactor. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'c1' ) ), - ) - ); - - $data = $response->get_data(); - $this->assertTrue( $data['rooms'][0]['should_compact'] ); - } - - public function test_sync_should_compact_is_false_for_non_compactor() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $updates = array(); - for ( $i = 0; $i < 51; $i++ ) { - $updates[] = array( - 'type' => 'update', - 'data' => base64_encode( "update-$i" ), - ); - } - - // Client 1 sends enough updates to exceed the compaction threshold. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $updates ), - ) - ); - - // Client 2 (higher ID than client 1) should not be the compactor. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), - ) - ); - - $data = $response->get_data(); - $this->assertFalse( $data['rooms'][0]['should_compact'] ); - } - - public function test_sync_stale_compaction_succeeds_when_newer_compaction_exists() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'dGVzdA==', - ); - - // Client 1 sends an update to seed the room. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), - ) - ); - - $end_cursor = $response->get_data()['rooms'][0]['end_cursor']; - - // Client 2 sends a compaction at the current cursor. - $compaction = array( - 'type' => 'compaction', - 'data' => 'Y29tcGFjdGVk', - ); - - $this->dispatch_sync( - array( - $this->build_room( $room, 2, $end_cursor, array( 'user' => 'c2' ), array( $compaction ) ), - ) - ); - - // Client 3 sends a stale compaction at cursor 0. The server should find - // client 2's compaction in the updates after cursor 0 and silently discard - // this one. - $stale_compaction = array( - 'type' => 'compaction', - 'data' => 'c3RhbGU=', - ); - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 3, 0, array( 'user' => 'c3' ), array( $stale_compaction ) ), - ) - ); - - $this->assertSame( 200, $response->get_status() ); - - // Verify the newer compaction is preserved and the stale one was not stored. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 4, 0, array( 'user' => 'c4' ) ), - ) - ); - $update_data = wp_list_pluck( $response->get_data()['rooms'][0]['updates'], 'data' ); - - $this->assertContains( 'Y29tcGFjdGVk', $update_data, 'The newer compaction should be preserved.' ); - $this->assertNotContains( 'c3RhbGU=', $update_data, 'The stale compaction should not be stored.' ); - } - - /* - * Awareness tests. - */ - - public function test_sync_awareness_returned() { - wp_set_current_user( self::$editor_id ); - - $awareness = array( 'name' => 'Editor' ); - $response = $this->dispatch_sync( - array( - $this->build_room( $this->get_post_room(), 1, 0, $awareness ), - ) - ); - - $data = $response->get_data(); - $this->assertArrayHasKey( 1, $data['rooms'][0]['awareness'] ); - $this->assertSame( $awareness, $data['rooms'][0]['awareness'][1] ); - } - - public function test_sync_awareness_shows_multiple_clients() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - - // Client 1 connects. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'name' => 'Client 1' ) ), - ) - ); - - // Client 2 connects. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0, array( 'name' => 'Client 2' ) ), - ) - ); - - $data = $response->get_data(); - $awareness = $data['rooms'][0]['awareness']; - - $this->assertArrayHasKey( 1, $awareness ); - $this->assertArrayHasKey( 2, $awareness ); - $this->assertSame( array( 'name' => 'Client 1' ), $awareness[1] ); - $this->assertSame( array( 'name' => 'Client 2' ), $awareness[2] ); - } - - public function test_sync_awareness_updates_existing_client() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - - // Client 1 connects with initial awareness. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'cursor' => 'start' ) ), - ) - ); - - // Client 1 updates its awareness. - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'cursor' => 'updated' ) ), - ) - ); - - $data = $response->get_data(); - $awareness = $data['rooms'][0]['awareness']; - - // Should have exactly one entry for client 1 with updated state. - $this->assertCount( 1, $awareness ); - $this->assertSame( array( 'cursor' => 'updated' ), $awareness[1] ); - } - - public function test_sync_awareness_client_id_cannot_be_used_by_another_user() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - - // Editor establishes awareness with client_id 1. - $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'name' => 'Editor' ) ), - ) - ); - - // A different user tries to use the same client_id. - $editor_id_2 = self::factory()->user->create( array( 'role' => 'editor' ) ); - wp_set_current_user( $editor_id_2 ); - - $response = $this->dispatch_sync( - array( - $this->build_room( $room, 1, 0, array( 'name' => 'Impostor' ) ), - ) - ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - /* - * Multiple rooms tests. - */ - - public function test_sync_multiple_rooms_in_single_request() { - wp_set_current_user( self::$editor_id ); - - $room1 = $this->get_post_room(); - $room2 = 'taxonomy/category'; - - $response = $this->dispatch_sync( - array( - $this->build_room( $room1 ), - $this->build_room( $room2 ), - ) - ); - - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - $this->assertCount( 2, $data['rooms'] ); - $this->assertSame( $room1, $data['rooms'][0]['room'] ); - $this->assertSame( $room2, $data['rooms'][1]['room'] ); - } - - public function test_sync_rooms_are_isolated() { - wp_set_current_user( self::$editor_id ); - - $post_id_2 = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); - $room1 = $this->get_post_room(); - $room2 = 'postType/post:' . $post_id_2; - - $update = array( - 'type' => 'update', - 'data' => 'cm9vbTEgb25seQ==', - ); - - // Client 1 sends an update to room 1 only. - $this->dispatch_sync( - array( - $this->build_room( $room1, 1, 0, array( 'user' => 'client1' ), array( $update ) ), - ) - ); - - // Client 2 queries both rooms. - $response = $this->dispatch_sync( - array( - $this->build_room( $room1, 2, 0 ), - $this->build_room( $room2, 2, 0 ), - ) - ); - - $data = $response->get_data(); - - // Room 1 should have the update. - $this->assertNotEmpty( $data['rooms'][0]['updates'] ); - - // Room 2 should have no updates. - $this->assertEmpty( $data['rooms'][1]['updates'] ); - } -} diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 4f925d35c82f6..58a2871c22a74 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -21,6 +21,7 @@ mockedApiResponse.Schema = { "wp-site-health/v1", "wp-block-editor/v1", "wp-abilities/v1", + "wp-collaboration/v1", "wp-sync/v1" ], "authentication": { @@ -3719,26 +3720,6 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/media/(?P[\\d]+)/finalize": { - "namespace": "wp/v2", - "methods": [ - "POST" - ], - "endpoints": [ - { - "methods": [ - "POST" - ], - "args": { - "id": { - "description": "Unique identifier for the attachment.", - "type": "integer", - "required": false - } - } - } - ] - }, "/wp/v2/menu-items": { "namespace": "wp/v2", "methods": [ @@ -11086,6 +11067,24 @@ mockedApiResponse.Schema = { "PATCH" ], "args": { + "connectors_ai_anthropic_api_key": { + "title": "Anthropic API Key", + "description": "API key for the Anthropic AI provider.", + "type": "string", + "required": false + }, + "connectors_ai_google_api_key": { + "title": "Google API Key", + "description": "API key for the Google AI provider.", + "type": "string", + "required": false + }, + "connectors_ai_openai_api_key": { + "title": "OpenAI API Key", + "description": "API key for the OpenAI AI provider.", + "type": "string", + "required": false + }, "title": { "title": "Title", "description": "Site title.", @@ -12774,6 +12773,115 @@ mockedApiResponse.Schema = { } ] }, + "/wp-collaboration/v1": { + "namespace": "wp-collaboration/v1", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "namespace": { + "default": "wp-collaboration/v1", + "required": false + }, + "context": { + "default": "view", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-collaboration/v1" + } + ] + } + }, + "/wp-collaboration/v1/updates": { + "namespace": "wp-collaboration/v1", + "methods": [ + "POST" + ], + "endpoints": [ + { + "methods": [ + "POST" + ], + "args": { + "rooms": { + "items": { + "properties": { + "after": { + "minimum": 0, + "required": true, + "type": "integer" + }, + "awareness": { + "required": true, + "type": [ + "object", + "null" + ] + }, + "client_id": { + "minimum": 1, + "required": true, + "type": "integer" + }, + "room": { + "required": true, + "type": "string", + "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", + "maxLength": 255 + }, + "updates": { + "items": { + "properties": { + "data": { + "type": "string", + "required": true + }, + "type": { + "type": "string", + "required": true, + "enum": [ + "compaction", + "sync_step1", + "sync_step2", + "update" + ] + } + }, + "required": true, + "type": "object" + }, + "minItems": 0, + "required": true, + "type": "array" + } + }, + "type": "object" + }, + "type": "array", + "required": true + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-collaboration/v1/updates" + } + ] + } + }, "/wp-sync/v1": { "namespace": "wp-sync/v1", "methods": [ @@ -12838,7 +12946,8 @@ mockedApiResponse.Schema = { "room": { "required": true, "type": "string", - "pattern": "^[^/]+/[^/:]+(?::\\S+)?$" + "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", + "maxLength": 255 }, "updates": { "items": { @@ -14764,6 +14873,9 @@ mockedApiResponse.CommentModel = { }; mockedApiResponse.settings = { + "connectors_ai_anthropic_api_key": "", + "connectors_ai_google_api_key": "", + "connectors_ai_openai_api_key": "", "title": "Test Blog", "description": "", "url": "http://example.org", From 6827989cbebd3fad9f7f87bc529ffbfde50e96d1 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:13:45 -0400 Subject: [PATCH 06/42] Collaboration: Use persistent object cache for awareness reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a cache-first read path to get_awareness_state() following the transient pattern: check the persistent object cache, fall back to the database on miss, and prime the cache with the result. set_awareness_state() updates the cached entries in-place after the DB write rather than invalidating, so the cache stays warm for the next reader in the room. This is application-level deduplication: the shared collaboration table cannot carry a UNIQUE KEY on (room, client_id) because sync rows need multiple entries per room+client pair. Sites without a persistent cache see no behavior change — the in-memory WP_Object_Cache provides no cross-request benefit but keeps the code path identical. --- .../class-wp-collaboration-table-storage.php | 69 ++++++++++++++-- .../rest-api/rest-collaboration-server.php | 79 +++++++++++++++++++ 2 files changed, 142 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index f60508ad1c53d..d9718784a5620 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -11,7 +11,9 @@ * updates and awareness data during a collaborative session. * * All data is stored in the single `collaboration` database table, - * discriminated by the `type` column. + * discriminated by the `type` column. Awareness reads are served from + * the persistent object cache when available, falling back to the + * database — similar to the transient pattern but without wp_options. * * This class intentionally fires no actions or filters. Collaboration * queries run on every poll (0.5–1 s per editor tab), so hook overhead @@ -72,9 +74,14 @@ public function add_update( string $room, $update ): bool { /** * Gets awareness state for a given room. * - * Retrieves per-client awareness rows from the collaboration table - * where type = 'awareness'. Expired rows are filtered by the WHERE - * clause; actual deletion is handled by cron via + * Checks the persistent object cache first. On a cache miss, queries + * the collaboration table for awareness rows and primes the cache + * with the result. When no persistent cache is available the in-memory + * WP_Object_Cache is used, which provides no cross-request benefit + * but keeps the code path identical. + * + * Expired rows are filtered by the WHERE clause on cache miss; + * actual deletion is handled by cron via * wp_delete_old_collaboration_data(). * * @since 7.0.0 @@ -87,6 +94,13 @@ public function add_update( string $room, $update ): bool { * @phpstan-return list */ public function get_awareness_state( string $room, int $timeout = 30 ): array { + $cache_key = 'awareness:' . $room; + $cached = wp_cache_get( $cache_key, 'collaboration' ); + + if ( false !== $cached ) { + return $cached; + } + global $wpdb; $cutoff = gmdate( 'Y-m-d H:i:s', time() - $timeout ); @@ -115,6 +129,8 @@ public function get_awareness_state( string $room, int $timeout = 30 ): array { } } + wp_cache_set( $cache_key, $entries, 'collaboration', $timeout ); + return $entries; } @@ -257,6 +273,13 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool * its own row, eliminating the race condition inherent in shared-state * approaches. * + * After writing, the cached awareness entries for the room are updated + * in-place so that subsequent get_awareness_state() calls from other + * clients hit the cache instead of the database. This is application- + * level deduplication: the shared collaboration table cannot carry a + * UNIQUE KEY on (room, client_id) because sync rows need multiple + * entries per room+client pair. + * * @since 7.0.0 * * @global wpdb $wpdb WordPress database abstraction object. @@ -302,9 +325,43 @@ public function set_awareness_state( string $room, string $client_id, array $sta ) ); - return false !== $result; + if ( false === $result ) { + return false; + } + } elseif ( false === $updated ) { + return false; + } + + // Update the cached entries in-place so the next reader in this + // room gets a cache hit with fresh data. If the cache is cold, + // skip — the next get_awareness_state() call will prime it. + $cache_key = 'awareness:' . $room; + $cached = wp_cache_get( $cache_key, 'collaboration' ); + + if ( false !== $cached ) { + $normalized_state = json_decode( $update_value, true ); + $found = false; + + foreach ( $cached as $i => $entry ) { + if ( $client_id === $entry['client_id'] ) { + $cached[ $i ]['state'] = $normalized_state; + $cached[ $i ]['user_id'] = $user_id; + $found = true; + break; + } + } + + if ( ! $found ) { + $cached[] = array( + 'client_id' => $client_id, + 'state' => $normalized_state, + 'user_id' => $user_id, + ); + } + + wp_cache_set( $cache_key, $cached, 'collaboration', 30 ); } - return false !== $updated; + return true; } } diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index c4f95f0380efe..9cd88a949dc17 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1671,6 +1671,85 @@ public function test_collaboration_null_awareness_skips_write() { $this->assertSame( array( 'cursor' => 'active' ), $awareness['1'], 'Client 1 awareness state should match.' ); } + /* + * Cache tests. + */ + + /** + * Verifies that a normal awareness write updates the cache in-place + * so the next client's poll hits the cache instead of the database. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_cache_hit_after_write(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 polls with awareness — primes cache via get, then + // updates it in-place via set. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + // Client 2 polls — awareness read should hit the warm cache. + $queries_before = $wpdb->num_queries; + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'cursor' => 'pos-b' ) ), + ) + ); + + $queries_after = $wpdb->num_queries; + + // With cache hit: awareness read is free, so: + // awareness UPDATE (1) + snapshot SELECT (1) + awareness INSERT (1) = 3. + // Without cache: adds awareness SELECT = 4. + $this->assertLessThanOrEqual( + 3, + $queries_after - $queries_before, + 'Awareness cache hit should skip the awareness SELECT query.' + ); + } + + /** + * Verifies that the in-place cache update after a write produces + * correct data, not stale state. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_cache_reflects_latest_write(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sets initial awareness. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'initial' ) ), + ) + ); + + // Client 1 updates awareness to a new value. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'updated' ) ), + ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertSame( + array( 'cursor' => 'updated' ), + $awareness['1'], + 'Awareness should reflect the updated state, not a stale cache.' + ); + } + /* * Query count tests. */ From 9bcbfe69068b4fcfc3903bd1016168c813d8d6fe Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 14:39:14 -0400 Subject: [PATCH 07/42] Tests: Fix REST schema and multisite test failures Restore the `wp_client_side_media_processing_enabled` filter and the `finalize` route that were accidentally removed from the REST schema test. Add the `collaboration` table to the list of tables expected to be empty after multisite site creation. --- tests/phpunit/tests/multisite/site.php | 2 +- tests/phpunit/tests/rest-api/rest-schema-setup.php | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/multisite/site.php b/tests/phpunit/tests/multisite/site.php index 920a76f6a7e30..cf371c8c30da6 100644 --- a/tests/phpunit/tests/multisite/site.php +++ b/tests/phpunit/tests/multisite/site.php @@ -179,7 +179,7 @@ public function test_created_site_details() { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $result = $wpdb->get_results( "SELECT * FROM $prefix$table LIMIT 1" ); - if ( 'commentmeta' === $table || 'termmeta' === $table || 'links' === $table ) { + if ( 'commentmeta' === $table || 'termmeta' === $table || 'links' === $table || 'collaboration' === $table ) { $this->assertEmpty( $result ); } else { $this->assertNotEmpty( $result ); diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 3b7a8c99e4e97..24b36a46a0fb3 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -16,6 +16,9 @@ class WP_Test_REST_Schema_Initialization extends WP_Test_REST_TestCase { public function set_up() { parent::set_up(); + // Ensure client-side media processing is enabled so the sideload route is registered. + add_filter( 'wp_client_side_media_processing_enabled', '__return_true' ); + // Ensure collaboration routes are registered. add_filter( 'pre_option_wp_enable_real_time_collaboration', '__return_true' ); @@ -113,6 +116,7 @@ public function test_expected_routes_in_schema() { '/wp/v2/media/(?P[\\d]+)/post-process', '/wp/v2/media/(?P[\\d]+)/edit', '/wp/v2/media/(?P[\\d]+)/sideload', + '/wp/v2/media/(?P[\\d]+)/finalize', '/wp/v2/blocks', '/wp/v2/blocks/(?P[\d]+)', '/wp/v2/blocks/(?P[\d]+)/autosaves', From 09d0b86326fa3b0e4bb1b26e280fb9f90dba17b8 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 15:19:09 -0400 Subject: [PATCH 08/42] Tests: Remove erroneous connector fixtures from merge artifact The connectors API key entries in wp-api-generated.js were incorrectly carried over during the trunk merge. Trunk does not include them in the generated fixtures since the settings are dynamically registered and not present in the CI test context. --- tests/qunit/fixtures/wp-api-generated.js | 65 +++++++++++++----------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 58a2871c22a74..418c53add3c60 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3720,6 +3720,26 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/media/(?P[\\d]+)/finalize": { + "namespace": "wp/v2", + "methods": [ + "POST" + ], + "endpoints": [ + { + "methods": [ + "POST" + ], + "args": { + "id": { + "description": "Unique identifier for the attachment.", + "type": "integer", + "required": false + } + } + } + ] + }, "/wp/v2/menu-items": { "namespace": "wp/v2", "methods": [ @@ -11067,24 +11087,6 @@ mockedApiResponse.Schema = { "PATCH" ], "args": { - "connectors_ai_anthropic_api_key": { - "title": "Anthropic API Key", - "description": "API key for the Anthropic AI provider.", - "type": "string", - "required": false - }, - "connectors_ai_google_api_key": { - "title": "Google API Key", - "description": "API key for the Google AI provider.", - "type": "string", - "required": false - }, - "connectors_ai_openai_api_key": { - "title": "OpenAI API Key", - "description": "API key for the OpenAI AI provider.", - "type": "string", - "required": false - }, "title": { "title": "Title", "description": "Site title.", @@ -12830,22 +12832,26 @@ mockedApiResponse.Schema = { ] }, "client_id": { - "minimum": 1, "required": true, - "type": "integer" + "type": [ + "string", + "integer" + ], + "sanitize_callback": {} }, "room": { "required": true, "type": "string", "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", - "maxLength": 255 + "maxLength": 191 }, "updates": { "items": { "properties": { "data": { "type": "string", - "required": true + "required": true, + "maxLength": 1048576 }, "type": { "type": "string", @@ -12939,22 +12945,26 @@ mockedApiResponse.Schema = { ] }, "client_id": { - "minimum": 1, "required": true, - "type": "integer" + "type": [ + "string", + "integer" + ], + "sanitize_callback": {} }, "room": { "required": true, "type": "string", "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", - "maxLength": 255 + "maxLength": 191 }, "updates": { "items": { "properties": { "data": { "type": "string", - "required": true + "required": true, + "maxLength": 1048576 }, "type": { "type": "string", @@ -14873,9 +14883,6 @@ mockedApiResponse.CommentModel = { }; mockedApiResponse.settings = { - "connectors_ai_anthropic_api_key": "", - "connectors_ai_google_api_key": "", - "connectors_ai_openai_api_key": "", "title": "Test Blog", "description": "", "url": "http://example.org", From 7455141251c543220ce0238e821a6e3891fe91c0 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 22:09:47 -0400 Subject: [PATCH 09/42] Collaboration: Rename update_value column to data Rename the `update_value` column to `data` in the collaboration table storage class and tests, and fix array arrow alignment to satisfy PHPCS. The shorter name is consistent with WordPress meta tables and avoids confusion with the `update_value()` method in `WP_REST_Meta_Fields`. --- .../class-wp-collaboration-table-storage.php | 42 ++++++------- .../rest-api/rest-collaboration-server.php | 60 +++++++++---------- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index d9718784a5620..7827a71dd63f5 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -59,11 +59,11 @@ public function add_update( string $room, $update ): bool { $result = $wpdb->insert( $wpdb->collaboration, array( - 'room' => $room, - 'type' => $update['type'] ?? '', - 'client_id' => $update['client_id'] ?? '', - 'update_value' => wp_json_encode( $update ), - 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'room' => $room, + 'type' => $update['type'] ?? '', + 'client_id' => $update['client_id'] ?? '', + 'data' => wp_json_encode( $update ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), ), array( '%s', '%s', '%s', '%s', '%s' ) ); @@ -107,7 +107,7 @@ public function get_awareness_state( string $room, int $timeout = 30 ): array { $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT client_id, user_id, update_value FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND date_gmt >= %s", + "SELECT client_id, user_id, data FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND date_gmt >= %s", $room, $cutoff ) @@ -119,7 +119,7 @@ public function get_awareness_state( string $room, int $timeout = 30 ): array { $entries = array(); foreach ( $rows as $row ) { - $decoded = json_decode( $row->update_value, true ); + $decoded = json_decode( $row->data, true ); if ( is_array( $decoded ) ) { $entries[] = array( 'client_id' => $row->client_id, @@ -215,7 +215,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { // Fetch updates after the cursor up to the snapshot boundary. $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT update_value FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id > %d AND id <= %d ORDER BY id ASC", + "SELECT data FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id > %d AND id <= %d ORDER BY id ASC", $room, $cursor, $max_id @@ -228,7 +228,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { $updates = array(); foreach ( $rows as $row ) { - $decoded = json_decode( $row->update_value, true ); + $decoded = json_decode( $row->data, true ); if ( is_array( $decoded ) ) { $updates[] = $decoded; } @@ -293,16 +293,16 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool public function set_awareness_state( string $room, string $client_id, array $state, int $user_id ): bool { global $wpdb; - $update_value = wp_json_encode( $state ); - $now = gmdate( 'Y-m-d H:i:s' ); + $data = wp_json_encode( $state ); + $now = gmdate( 'Y-m-d H:i:s' ); // Try UPDATE first. $updated = $wpdb->update( $wpdb->collaboration, array( - 'user_id' => $user_id, - 'update_value' => $update_value, - 'date_gmt' => $now, + 'user_id' => $user_id, + 'data' => $data, + 'date_gmt' => $now, ), array( 'room' => $room, @@ -316,12 +316,12 @@ public function set_awareness_state( string $room, string $client_id, array $sta $result = $wpdb->insert( $wpdb->collaboration, array( - 'room' => $room, - 'type' => 'awareness', - 'client_id' => $client_id, - 'user_id' => $user_id, - 'update_value' => $update_value, - 'date_gmt' => $now, + 'room' => $room, + 'type' => 'awareness', + 'client_id' => $client_id, + 'user_id' => $user_id, + 'data' => $data, + 'date_gmt' => $now, ) ); @@ -339,7 +339,7 @@ public function set_awareness_state( string $room, string $client_id, array $sta $cached = wp_cache_get( $cache_key, 'collaboration' ); if ( false !== $cached ) { - $normalized_state = json_decode( $update_value, true ); + $normalized_state = json_decode( $data, true ); $found = false; foreach ( $cached as $i => $entry ) { diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 9cd88a949dc17..122dbbe378c06 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1157,7 +1157,7 @@ public function test_collaboration_compaction_reduces_total_updates(): void { * Inserts a row directly into the collaboration table with a given age. * * @param positive-int $age_in_seconds How old the row should be. - * @param string $label A label stored in the update_value for identification. + * @param string $label A label stored in the data column for identification. */ private function insert_collaboration_row( int $age_in_seconds, string $label = 'test' ): void { global $wpdb; @@ -1165,16 +1165,16 @@ private function insert_collaboration_row( int $age_in_seconds, string $label = $wpdb->insert( $wpdb->collaboration, array( - 'room' => $this->get_post_room(), - 'type' => 'update', - 'client_id' => '1', - 'update_value' => wp_json_encode( + 'room' => $this->get_post_room(), + 'type' => 'update', + 'client_id' => '1', + 'data' => wp_json_encode( array( 'type' => 'update', 'data' => $label, ) ), - 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), ), array( '%s', '%s', '%s', '%s', '%s' ) ); @@ -1236,7 +1236,7 @@ public function test_cron_cleanup_boundary_at_exactly_seven_days(): void { wp_delete_old_collaboration_data(); global $wpdb; - $remaining = $wpdb->get_col( "SELECT update_value FROM {$wpdb->collaboration}" ); + $remaining = $wpdb->get_col( "SELECT data FROM {$wpdb->collaboration}" ); $this->assertCount( 1, $remaining, 'Only the row within the 7-day window should remain.' ); $this->assertStringContainsString( 'just-inside', $remaining[0], 'The surviving row should be the one inside the window.' ); @@ -1289,7 +1289,7 @@ public function test_collaboration_routes_not_registered_when_db_version_is_old( $server = rest_get_server(); $routes = $server->get_routes(); - $this->assertArrayNotHasKey( '/wp-collaboration/v1/updates', $routes, 'Collaboration routes should not be registered when db_version is below 61840.' ); + $this->assertArrayNotHasKey( '/wp-collaboration/v1/updates', $routes, 'Collaboration routes should not be registered when db_version is below 61841.' ); // Reset again so subsequent tests get a server with the correct db_version. $GLOBALS['wp_rest_server'] = null; @@ -1441,12 +1441,12 @@ public function test_collaboration_expired_awareness_rows_cleaned_up(): void { $wpdb->insert( $wpdb->collaboration, array( - 'room' => $room, - 'type' => 'awareness', - 'client_id' => '99', - 'user_id' => self::$editor_id, - 'update_value' => wp_json_encode( array( 'cursor' => 'stale' ) ), - 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), + 'room' => $room, + 'type' => 'awareness', + 'client_id' => '99', + 'user_id' => self::$editor_id, + 'data' => wp_json_encode( array( 'cursor' => 'stale' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), ), array( '%s', '%s', '%s', '%d', '%s', '%s' ) ); @@ -1497,12 +1497,12 @@ public function test_cron_cleanup_deletes_expired_awareness_rows(): void { $wpdb->insert( $wpdb->collaboration, array( - 'room' => $this->get_post_room(), - 'type' => 'awareness', - 'client_id' => '42', - 'user_id' => self::$editor_id, - 'update_value' => wp_json_encode( array( 'cursor' => 'old' ) ), - 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), + 'room' => $this->get_post_room(), + 'type' => 'awareness', + 'client_id' => '42', + 'user_id' => self::$editor_id, + 'data' => wp_json_encode( array( 'cursor' => 'old' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), ), array( '%s', '%s', '%s', '%d', '%s', '%s' ) ); @@ -1521,7 +1521,7 @@ public function test_cron_cleanup_deletes_expired_awareness_rows(): void { /** * Verifies that user_id is stored as a dedicated column, - * not embedded inside the update_value JSON blob. + * not embedded inside the data JSON blob. * * @ticket 64696 */ @@ -1539,7 +1539,7 @@ public function test_collaboration_awareness_user_id_round_trip() { // Query the collaboration table directly for the awareness row. $row = $wpdb->get_row( $wpdb->prepare( - "SELECT user_id, update_value FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s", + "SELECT user_id, data FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s", $room, '1' ) @@ -1547,12 +1547,12 @@ public function test_collaboration_awareness_user_id_round_trip() { $this->assertNotNull( $row, 'Awareness row should exist.' ); $this->assertSame( self::$editor_id, (int) $row->user_id, 'user_id column should match the editor.' ); - $this->assertStringNotContainsString( 'user_id', $row->update_value, 'update_value should not contain user_id.' ); + $this->assertStringNotContainsString( 'user_id', $row->data, 'data column should not contain user_id.' ); } /** * Verifies that the is_array() guard in get_awareness_state() skips - * rows where update_value contains valid JSON that is not an array. + * rows where the data column contains valid JSON that is not an array. * * @ticket 64696 */ @@ -1567,12 +1567,12 @@ public function test_collaboration_awareness_non_array_json_ignored() { $wpdb->insert( $wpdb->collaboration, array( - 'room' => $room, - 'type' => 'awareness', - 'client_id' => '99', - 'user_id' => self::$editor_id, - 'update_value' => '"hello"', - 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'room' => $room, + 'type' => 'awareness', + 'client_id' => '99', + 'user_id' => self::$editor_id, + 'data' => '"hello"', + 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), ), array( '%s', '%s', '%s', '%d', '%s', '%s' ) ); From d4e27d4748391f00384cae3dd7d20972ddad8d00 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 22:09:53 -0400 Subject: [PATCH 10/42] Collaboration: Add type_client_id index and bump db_version Add a composite index on (type, client_id) to the collaboration table to speed up awareness upserts, which filter on both columns. Bump $wp_db_version from 61840 to 61841 so existing installations pick up the schema change via dbDelta on upgrade. --- src/wp-admin/includes/schema.php | 3 ++- src/wp-admin/includes/upgrade.php | 2 +- src/wp-includes/collaboration.php | 4 ++-- src/wp-includes/version.php | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 36d39b7b5d497..2269bcc2c1156 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -193,9 +193,10 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { type varchar(32) NOT NULL default '', client_id varchar(32) NOT NULL default '', user_id bigint(20) unsigned NOT NULL default '0', - update_value longtext NOT NULL, + data longtext NOT NULL, date_gmt datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), + KEY type_client_id (type,client_id), KEY room (room,id), KEY date_gmt (date_gmt) ) $charset_collate;\n"; diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 0fcfe4acd1077..a601be26bc659 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -886,7 +886,7 @@ function upgrade_all() { upgrade_682(); } - if ( $wp_current_db_version < 61840 ) { + if ( $wp_current_db_version < 61841 ) { upgrade_700(); } diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 1ad489deac89a..a9efe66451c9a 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -10,7 +10,7 @@ * Checks whether real-time collaboration is enabled. * * The feature requires both the site option and the database schema - * introduced in db_version 61840. + * introduced in db_version 61841. * * @since 7.0.0 * @@ -18,7 +18,7 @@ */ function wp_is_collaboration_enabled() { return get_option( 'wp_enable_real_time_collaboration' ) - && get_option( 'db_version' ) >= 61840; + && get_option( 'db_version' ) >= 61841; } /** diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 02a9f4bc06025..b2c69c45c3f8c 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -23,7 +23,7 @@ * * @global int $wp_db_version */ -$wp_db_version = 61840; +$wp_db_version = 61841; /** * Holds the TinyMCE version. From 9b4517400806b7eaf43914fc6d61c46e1d9bf897 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 23:11:19 -0400 Subject: [PATCH 11/42] Collaboration: Add payload limit constants and request validation Introduce MAX_BODY_SIZE (16 MB), MAX_ROOMS_PER_REQUEST (50), and MAX_UPDATE_DATA_SIZE (1 MB) constants to cap request payloads. Wire a validate_callback on the route to reject oversized request bodies with a 413, add maxItems to the rooms schema, and replace the hardcoded maxLength with the new constant. --- ...s-wp-http-polling-collaboration-server.php | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index f36d4ba1bdb09..f205f4cb98181 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -38,6 +38,30 @@ class WP_HTTP_Polling_Collaboration_Server { */ const COMPACTION_THRESHOLD = 50; + /** + * Maximum allowed request body size in bytes. + * + * @since 7.0.0 + * @var int + */ + const MAX_BODY_SIZE = 16 * MB_IN_BYTES; + + /** + * Maximum number of rooms allowed per request. + * + * @since 7.0.0 + * @var int + */ + const MAX_ROOMS_PER_REQUEST = 50; + + /** + * Maximum allowed size for a single update's data field in bytes. + * + * @since 7.0.0 + * @var int + */ + const MAX_UPDATE_DATA_SIZE = MB_IN_BYTES; + /** * Collaboration update type: compaction. * @@ -100,7 +124,7 @@ public function register_routes(): void { 'data' => array( 'type' => 'string', 'required' => true, - 'maxLength' => 1048576, // 1 MB — generous ceiling for base64-encoded Yjs updates. + 'maxLength' => self::MAX_UPDATE_DATA_SIZE, ), 'type' => array( 'type' => 'string', @@ -152,12 +176,14 @@ public function register_routes(): void { 'methods' => array( WP_REST_Server::CREATABLE ), 'callback' => array( $this, 'handle_request' ), 'permission_callback' => array( $this, 'check_permissions' ), + 'validate_callback' => array( $this, 'validate_request' ), 'args' => array( 'rooms' => array( 'items' => array( 'properties' => $room_args, 'type' => 'object', ), + 'maxItems' => self::MAX_ROOMS_PER_REQUEST, 'required' => true, 'type' => 'array', ), @@ -236,6 +262,28 @@ public function check_permissions( WP_REST_Request $request ) { return true; } + /** + * Validates the incoming REST request. + * + * Checks that the raw request body does not exceed the maximum allowed size. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request The REST request. + * @return true|WP_Error True if valid, WP_Error if body is too large. + */ + public function validate_request( WP_REST_Request $request ) { + $body = $request->get_body(); + if ( is_string( $body ) && strlen( $body ) > self::MAX_BODY_SIZE ) { + return new WP_Error( + 'rest_collaboration_body_too_large', + __( 'Request body is too large.' ), + array( 'status' => 413 ) + ); + } + return true; + } + /** * Handles request: stores updates and awareness data, and returns * updates the client is missing. From dd319b8356342bc5b160baf8ebaf5b84b06441a6 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 23:11:33 -0400 Subject: [PATCH 12/42] Collaboration: Harden entity permission checks Reject non-numeric object IDs early in can_user_collaborate_on_entity_type(). Verify that a post's actual type matches the room's claimed entity name before granting access. For taxonomy rooms, confirm the term exists in the specified taxonomy and simplify the capability check to use assign_term with the term's object ID. --- .../class-wp-http-polling-collaboration-server.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index f205f4cb98181..ca501aa387028 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -347,15 +347,25 @@ public function handle_request( WP_REST_Request $request ) { * @return bool True if user has permission, otherwise false. */ private function can_user_collaborate_on_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool { + // Reject non-numeric object IDs early. + if ( ! is_null( $object_id ) && ! is_numeric( $object_id ) ) { + return false; + } + // Handle single post type entities with a defined object ID. if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { + if ( get_post_type( $object_id ) !== $entity_name ) { + return false; + } return current_user_can( 'edit_post', (int) $object_id ); } // Handle single taxonomy term entities with a defined object ID. if ( 'taxonomy' === $entity_kind && is_numeric( $object_id ) ) { - $taxonomy = get_taxonomy( $entity_name ); - return isset( $taxonomy->cap->assign_terms ) && current_user_can( $taxonomy->cap->assign_terms ); + if ( ! term_exists( (int) $object_id, $entity_name ) ) { + return false; + } + return current_user_can( 'assign_term', (int) $object_id ); } // Handle single comment entities with a defined object ID. From cd4a69fba53ee88c08cae64192471063c72a2f03 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 23:11:44 -0400 Subject: [PATCH 13/42] Collaboration: Add tests for payload limits and permission hardening Cover oversized request body (413), exceeding max rooms (400), non-numeric object ID, post type mismatch, nonexistent taxonomy term, and term in the wrong taxonomy. --- .../rest-api/rest-collaboration-server.php | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 122dbbe378c06..198862fbbc23a 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1801,6 +1801,132 @@ public function test_collaboration_deprecated_sync_route() { $this->assertContains( 'c3luYyByb3V0ZQ==', $update_data ); } + /* + * Payload limit and permission hardening tests. + */ + + /** + * Verifies that a request body exceeding MAX_BODY_SIZE returns a 413 error. + * + * @ticket 64696 + */ + public function test_collaboration_oversized_body_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + // Set a body larger than MAX_BODY_SIZE (16 MB). + $request->set_body( str_repeat( 'x', 16 * MB_IN_BYTES + 1 ) ); + $request->set_body_params( + array( + 'rooms' => array( + $this->build_room( $this->get_post_room() ), + ), + ) + ); + + $server = new WP_HTTP_Polling_Collaboration_Server( + new WP_Collaboration_Table_Storage() + ); + + $result = $server->validate_request( $request ); + + $this->assertWPError( $result ); + $this->assertSame( 'rest_collaboration_body_too_large', $result->get_error_code() ); + $this->assertSame( 413, $result->get_error_data()['status'] ); + } + + /** + * Verifies that more than MAX_ROOMS_PER_REQUEST rooms is rejected by schema validation. + * + * @ticket 64696 + */ + public function test_collaboration_too_many_rooms_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $rooms = array(); + for ( $i = 0; $i <= WP_HTTP_Polling_Collaboration_Server::MAX_ROOMS_PER_REQUEST; $i++ ) { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $rooms[] = $this->build_room( 'postType/post:' . $post_id, (string) $i ); + } + + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 400, $response->get_status(), 'Exceeding MAX_ROOMS_PER_REQUEST should return 400.' ); + } + + /** + * Verifies that a non-numeric object ID in a room name is rejected. + * + * @ticket 64696 + */ + public function test_collaboration_non_numeric_object_id_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'postType/post:abc' ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * Verifies that a post type mismatch (room says page but post is a post) is rejected. + * + * @ticket 64696 + */ + public function test_collaboration_post_type_mismatch_rejected(): void { + wp_set_current_user( self::$editor_id ); + + // self::$post_id is a 'post', but the room claims 'page'. + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'postType/page:' . self::$post_id ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * Verifies that a taxonomy term that doesn't exist is rejected. + * + * @ticket 64696 + */ + public function test_collaboration_nonexistent_taxonomy_term_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'taxonomy/category:999999' ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * Verifies that a taxonomy term in the wrong taxonomy is rejected. + * + * @ticket 64696 + */ + public function test_collaboration_taxonomy_term_wrong_taxonomy_rejected(): void { + wp_set_current_user( self::$editor_id ); + + // Create a term in 'category' taxonomy. + $term = self::factory()->term->create( array( 'taxonomy' => 'category' ) ); + + // Try to access it as a 'post_tag' term. + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'taxonomy/post_tag:' . $term ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + /** * An idle poll (no new updates) should use at most 4 queries per room: * 1. SELECT … FROM collaboration WHERE type = 'awareness' (read + ownership check) From 442798f904d2f13a44f2aeb8ba79b1b7669b3b85 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 13:42:54 -0400 Subject: [PATCH 14/42] Collaboration: Apply coding standards and clarifications to table storage Convert consecutive single-line comments to block comment style per WordPress coding standards, replace forward slashes with colons in cache keys to avoid ambiguity, hoist `global $wpdb` above the cache check in `get_awareness_state()`, and clarify the `$cursor` param docblock in `remove_updates_before_cursor()`. --- .../class-wp-collaboration-table-storage.php | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 7827a71dd63f5..6f31e293e473e 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -94,15 +94,15 @@ public function add_update( string $room, $update ): bool { * @phpstan-return list */ public function get_awareness_state( string $room, int $timeout = 30 ): array { - $cache_key = 'awareness:' . $room; + global $wpdb; + + $cache_key = 'awareness:' . str_replace( '/', ':', $room ); $cached = wp_cache_get( $cache_key, 'collaboration' ); if ( false !== $cached ) { return $cached; } - global $wpdb; - $cutoff = gmdate( 'Y-m-d H:i:s', time() - $timeout ); $rows = $wpdb->get_results( @@ -182,8 +182,10 @@ public function get_update_count( string $room ): int { public function get_updates_after_cursor( string $room, int $cursor ): array { global $wpdb; - // Snapshot the current max ID and total row count in a single query. - // Excludes awareness rows — they are not sync updates. + /* + * Snapshot the current max ID and total row count in a single query. + * Excludes awareness rows — they are not sync updates. + */ $snapshot = $wpdb->get_row( $wpdb->prepare( "SELECT COALESCE( MAX( id ), 0 ) AS max_id, COUNT(*) AS total FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'", @@ -203,16 +205,18 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { $this->room_cursors[ $room ] = $max_id; if ( 0 === $max_id || $max_id <= $cursor ) { - // Preserve the real row count so the server can still - // trigger compaction when updates have accumulated but - // no new ones arrived since the client's last poll. + /* + * Preserve the real row count so the server can still + * trigger compaction when updates have accumulated but + * no new ones arrived since the client's last poll. + */ $this->room_update_counts[ $room ] = $total; return array(); } $this->room_update_counts[ $room ] = $total; - // Fetch updates after the cursor up to the snapshot boundary. + /* Fetch updates after the cursor up to the snapshot boundary. */ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT data FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id > %d AND id <= %d ORDER BY id ASC", @@ -248,7 +252,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { * @global wpdb $wpdb WordPress database abstraction object. * * @param string $room Room identifier. - * @param int $cursor Remove updates with id <= this cursor. + * @param int $cursor Remove updates up to and including this cursor. * @return bool True on success, false on failure. */ public function remove_updates_before_cursor( string $room, int $cursor ): bool { @@ -296,7 +300,7 @@ public function set_awareness_state( string $room, string $client_id, array $sta $data = wp_json_encode( $state ); $now = gmdate( 'Y-m-d H:i:s' ); - // Try UPDATE first. + /* Try UPDATE first. */ $updated = $wpdb->update( $wpdb->collaboration, array( @@ -311,7 +315,7 @@ public function set_awareness_state( string $room, string $client_id, array $sta ) ); - // INSERT only if no existing row. + /* INSERT only if no existing row. */ if ( 0 === (int) $updated ) { $result = $wpdb->insert( $wpdb->collaboration, @@ -332,10 +336,12 @@ public function set_awareness_state( string $room, string $client_id, array $sta return false; } - // Update the cached entries in-place so the next reader in this - // room gets a cache hit with fresh data. If the cache is cold, - // skip — the next get_awareness_state() call will prime it. - $cache_key = 'awareness:' . $room; + /* + * Update the cached entries in-place so the next reader in this + * room gets a cache hit with fresh data. If the cache is cold, + * skip — the next get_awareness_state() call will prime it. + */ + $cache_key = 'awareness:' . str_replace( '/', ':', $room ); $cached = wp_cache_get( $cache_key, 'collaboration' ); if ( false !== $cached ) { From 2e7c177b573d4e4e72c3e8a4bd16fad803b9bd68 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 13:43:01 -0400 Subject: [PATCH 15/42] Collaboration: Clean up stale data and unschedule cron when disabled When collaboration is disabled, run both DELETE queries (sync and awareness rows) before unscheduling the cron hook so leftover data is removed. Hoist `global $wpdb` to the top of the function so the disabled branch can use it. Add a comment noting future persistent types may also need exclusion from the sync cleanup query. --- src/wp-includes/collaboration.php | 32 ++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index a9efe66451c9a..f91e6a201d775 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -60,13 +60,39 @@ function wp_collaboration_inject_setting() { * @since 7.0.0 */ function wp_delete_old_collaboration_data() { + global $wpdb; + if ( ! wp_is_collaboration_enabled() ) { + /* + * Collaboration was enabled in the past but has since been disabled. + * Clean up any remaining stale data and unschedule the cron job + * so this callback does not continue to run. + */ + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE type != 'awareness' AND date_gmt < %s", + gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) + ) + ); + + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE type = 'awareness' AND date_gmt < %s", + gmdate( 'Y-m-d H:i:s', time() - 60 ) + ) + ); + + wp_clear_scheduled_hook( 'wp_delete_old_collaboration_data' ); return; } - global $wpdb; - - // Clean up sync rows older than 7 days. + /* + * Clean up sync rows older than 7 days. + * + * The type != 'awareness' exclusion keeps awareness rows untouched — + * they are cleaned up separately below. Future persistent types + * (e.g. persisted_crdt_doc) may also need exclusion here. + */ $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->collaboration} WHERE type != 'awareness' AND date_gmt < %s", From 24f4fdc37bba9c874984134cae49679754e091e1 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 13:43:05 -0400 Subject: [PATCH 16/42] Collaboration: Remove backward-compatible wp-sync/v1 route alias The wp-sync/v1 namespace was a transitional alias for the Gutenberg plugin. Remove it so only wp-collaboration/v1 is registered. --- .../class-wp-http-polling-collaboration-server.php | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index ca501aa387028..fb9023e09382d 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -195,20 +195,6 @@ public function register_routes(): void { '/updates', $route_args ); - - /* - * Backward-compatible alias so that the Gutenberg plugin's - * bundled sync package (which still uses wp-sync/v1) continues - * to work against WordPress 7.0+. - * - * @todo Remove once the Gutenberg plugin has transitioned to - * the wp-collaboration/v1 namespace. - */ - register_rest_route( - 'wp-sync/v1', - '/updates', - $route_args - ); } /** From 14ba5608e3a4706aebe1a1bd58177e1653c880d6 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 13:57:13 -0400 Subject: [PATCH 17/42] Collaboration: Move implementation details from docblock to code comment --- .../class-wp-collaboration-table-storage.php | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 6f31e293e473e..2ae81a929eb59 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -164,13 +164,6 @@ public function get_update_count( string $room ): int { /** * Retrieves updates from a room after a given cursor. * - * Uses a snapshot approach: captures MAX(id) and COUNT(*) in a single - * query, then fetches rows WHERE id > cursor AND id <= max_id. Updates - * arriving after the snapshot are deferred to the next poll, never lost. - * - * Only retrieves non-awareness rows — awareness rows are handled - * separately via get_awareness_state(). - * * @since 7.0.0 * * @global wpdb $wpdb WordPress database abstraction object. @@ -183,9 +176,15 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { global $wpdb; /* - * Snapshot the current max ID and total row count in a single query. - * Excludes awareness rows — they are not sync updates. + * Uses a snapshot approach: captures MAX(id) and COUNT(*) in a single + * query, then fetches rows WHERE id > cursor AND id <= max_id. Updates + * arriving after the snapshot are deferred to the next poll, never lost. + * + * Only retrieves non-awareness rows — awareness rows are handled + * separately via get_awareness_state(). */ + + /* Snapshot the current max ID and total row count in a single query. */ $snapshot = $wpdb->get_row( $wpdb->prepare( "SELECT COALESCE( MAX( id ), 0 ) AS max_id, COUNT(*) AS total FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'", From 318051fb63469768ae76c807ea2cbc96de01985c Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 13:58:47 -0400 Subject: [PATCH 18/42] Collaboration: Remove deprecated wp-sync/v1 route test The backward-compatible wp-sync/v1 route alias was removed in 24f4fdc37b, making this test invalid. --- .../rest-api/rest-collaboration-server.php | 47 ------------------- 1 file changed, 47 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 198862fbbc23a..1fe4b98ceff6e 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1754,53 +1754,6 @@ public function test_collaboration_awareness_cache_reflects_latest_write(): void * Query count tests. */ - /* - * Deprecated route tests. - */ - - /** - * Verifies the deprecated wp-sync/v1 route alias works identically to - * the canonical wp-collaboration/v1 namespace. - * - * @ticket 64696 - */ - public function test_collaboration_deprecated_sync_route() { - wp_set_current_user( self::$editor_id ); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => 'c3luYyByb3V0ZQ==', - ); - - // Send an update via the deprecated namespace. - $response = $this->dispatch_collaboration( - array( - $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), - ), - 'wp-sync/v1' - ); - - $this->assertSame( 200, $response->get_status(), 'Deprecated wp-sync/v1 route should return 200.' ); - - $data = $response->get_data(); - $this->assertArrayHasKey( 'rooms', $data, 'Response should contain rooms key.' ); - $this->assertSame( $room, $data['rooms'][0]['room'], 'Room identifier should match.' ); - - // Verify the update is retrievable via the canonical namespace. - $response2 = $this->dispatch_collaboration( - array( - $this->build_room( $room, '2', 0 ), - ) - ); - - $updates = $response2->get_data()['rooms'][0]['updates']; - $this->assertNotEmpty( $updates, 'Update sent via deprecated route should be retrievable via canonical route.' ); - - $update_data = wp_list_pluck( $updates, 'data' ); - $this->assertContains( 'c3luYyByb3V0ZQ==', $update_data ); - } - /* * Payload limit and permission hardening tests. */ From 543bc6b1c227ae3bf6f0c68bf56e3c21bb157810 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 18:32:25 -0400 Subject: [PATCH 19/42] Collaboration: Add test for client ID reactivation after awareness expiry --- .../rest-api/rest-collaboration-server.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 1fe4b98ceff6e..1f33e4feffc3e 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -846,6 +846,76 @@ public function test_collaboration_awareness_client_id_cannot_be_used_by_another $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } + /** + * Verifies that a client can reactivate with the same client ID after + * its awareness entry has expired (e.g., laptop closed and reopened). + * + * @ticket 64696 + */ + public function test_collaboration_awareness_client_reactivates_after_expiry() { + wp_set_current_user( self::$editor_id ); + global $wpdb; + + $room = $this->get_post_room(); + + // Client 1 registers awareness. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'before-sleep' ) ), + ) + ); + + // Simulate the client going idle beyond the awareness timeout + // by backdating its awareness row. + $wpdb->update( + $wpdb->collaboration, + array( 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ) ), + array( + 'room' => $room, + 'type' => 'awareness', + 'client_id' => '1', + ) + ); + + // Flush the object cache so get_awareness_state() hits the DB. + wp_cache_flush(); + + // Another client polls — the expired client should not appear. + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'cursor' => 'observer' ) ), + ) + ); + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertArrayNotHasKey( '1', $awareness, 'Expired client should not appear in awareness.' ); + + // Original user returns and reconnects with the same client_id. + wp_set_current_user( self::$editor_id ); + wp_cache_flush(); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'after-sleep' ) ), + ) + ); + $awareness = $response->get_data()['rooms'][0]['awareness']; + + $this->assertSame( 200, $response->get_status(), 'Reactivation should succeed.' ); + $this->assertArrayHasKey( '1', $awareness, 'Reactivated client should appear in awareness.' ); + $this->assertSame( array( 'cursor' => 'after-sleep' ), $awareness['1'], 'Reactivated client should have updated state.' ); + + // Verify no duplicate rows were created. + $row_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE type = 'awareness' AND room = %s AND client_id = %s", + $room, + '1' + ) + ); + $this->assertSame( 1, $row_count, 'Should have exactly one awareness row after reactivation.' ); + } + /* * Multiple rooms tests. */ From ba4ab78691459fd61d79e7de6b3c94ae405445b1 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 18:34:16 -0400 Subject: [PATCH 20/42] Revert "Collaboration: Remove deprecated wp-sync/v1 route test" This reverts commit 318051fb63469768ae76c807ea2cbc96de01985c. --- .../rest-api/rest-collaboration-server.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 1f33e4feffc3e..4273333ddf71e 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1824,6 +1824,53 @@ public function test_collaboration_awareness_cache_reflects_latest_write(): void * Query count tests. */ + /* + * Deprecated route tests. + */ + + /** + * Verifies the deprecated wp-sync/v1 route alias works identically to + * the canonical wp-collaboration/v1 namespace. + * + * @ticket 64696 + */ + public function test_collaboration_deprecated_sync_route() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'c3luYyByb3V0ZQ==', + ); + + // Send an update via the deprecated namespace. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ), + 'wp-sync/v1' + ); + + $this->assertSame( 200, $response->get_status(), 'Deprecated wp-sync/v1 route should return 200.' ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'rooms', $data, 'Response should contain rooms key.' ); + $this->assertSame( $room, $data['rooms'][0]['room'], 'Room identifier should match.' ); + + // Verify the update is retrievable via the canonical namespace. + $response2 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $updates = $response2->get_data()['rooms'][0]['updates']; + $this->assertNotEmpty( $updates, 'Update sent via deprecated route should be retrievable via canonical route.' ); + + $update_data = wp_list_pluck( $updates, 'data' ); + $this->assertContains( 'c3luYyByb3V0ZQ==', $update_data ); + } + /* * Payload limit and permission hardening tests. */ From d833d2f3247a9985f788f0a81cab05357b462804 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 18:34:29 -0400 Subject: [PATCH 21/42] Revert "Collaboration: Remove backward-compatible wp-sync/v1 route alias" This reverts commit 24f4fdc37bba9c874984134cae49679754e091e1. --- .../class-wp-http-polling-collaboration-server.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index fb9023e09382d..ca501aa387028 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -195,6 +195,20 @@ public function register_routes(): void { '/updates', $route_args ); + + /* + * Backward-compatible alias so that the Gutenberg plugin's + * bundled sync package (which still uses wp-sync/v1) continues + * to work against WordPress 7.0+. + * + * @todo Remove once the Gutenberg plugin has transitioned to + * the wp-collaboration/v1 namespace. + */ + register_rest_route( + 'wp-sync/v1', + '/updates', + $route_args + ); } /** From 030bbce3ce7c02b72370a06a3a01e7a341792798 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 23:40:49 -0400 Subject: [PATCH 22/42] Collaboration: Harden storage layer, fix duplicate awareness rows, and expand test coverage --- src/wp-includes/collaboration.php | 12 +- .../class-wp-collaboration-table-storage.php | 54 +- ...s-wp-http-polling-collaboration-server.php | 46 +- .../rest-api/rest-collaboration-server.php | 589 +++++++++++++++--- 4 files changed, 545 insertions(+), 156 deletions(-) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index f91e6a201d775..0b2ee8bf04c92 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -70,7 +70,7 @@ function wp_delete_old_collaboration_data() { */ $wpdb->query( $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE type != 'awareness' AND date_gmt < %s", + "DELETE FROM {$wpdb->collaboration} WHERE date_gmt < %s", gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) ) ); @@ -86,16 +86,10 @@ function wp_delete_old_collaboration_data() { return; } - /* - * Clean up sync rows older than 7 days. - * - * The type != 'awareness' exclusion keeps awareness rows untouched — - * they are cleaned up separately below. Future persistent types - * (e.g. persisted_crdt_doc) may also need exclusion here. - */ + /* Clean up rows older than 7 days. */ $wpdb->query( $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE type != 'awareness' AND date_gmt < %s", + "DELETE FROM {$wpdb->collaboration} WHERE date_gmt < %s", gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) ) ); diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 2ae81a929eb59..ba444e9868ea6 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -241,10 +241,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { } /** - * Removes updates from a room that are older than the given cursor. - * - * Uses a single atomic DELETE query, avoiding the race-prone - * "delete all, re-add some" pattern. + * Removes updates from a room up to and including the given cursor. * * @since 7.0.0 * @@ -254,9 +251,11 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { * @param int $cursor Remove updates up to and including this cursor. * @return bool True on success, false on failure. */ - public function remove_updates_before_cursor( string $room, int $cursor ): bool { + public function remove_updates_up_to_cursor( string $room, int $cursor ): bool { global $wpdb; + // Uses a single atomic DELETE query, avoiding the race-prone + // "delete all, re-add some" pattern. $result = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id <= %d", @@ -271,10 +270,10 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool /** * Sets awareness state for a given client in a room. * - * Uses UPDATE-then-INSERT: tries to update the existing row first, - * and only inserts if no row was updated. Each client writes only - * its own row, eliminating the race condition inherent in shared-state - * approaches. + * Uses SELECT-then-UPDATE/INSERT: checks for an existing row by + * primary key, then updates or inserts accordingly. Each client + * writes only its own row, eliminating the race condition inherent + * in shared-state approaches. * * After writing, the cached awareness entries for the room are updated * in-place so that subsequent get_awareness_state() calls from other @@ -299,23 +298,26 @@ public function set_awareness_state( string $room, string $client_id, array $sta $data = wp_json_encode( $state ); $now = gmdate( 'Y-m-d H:i:s' ); - /* Try UPDATE first. */ - $updated = $wpdb->update( - $wpdb->collaboration, - array( - 'user_id' => $user_id, - 'data' => $data, - 'date_gmt' => $now, - ), - array( - 'room' => $room, - 'type' => 'awareness', - 'client_id' => $client_id, + /* Check if a row already exists. */ + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s LIMIT 1", + $room, + $client_id ) ); - /* INSERT only if no existing row. */ - if ( 0 === (int) $updated ) { + if ( $exists ) { + $result = $wpdb->update( + $wpdb->collaboration, + array( + 'user_id' => $user_id, + 'data' => $data, + 'date_gmt' => $now, + ), + array( 'id' => $exists ) + ); + } else { $result = $wpdb->insert( $wpdb->collaboration, array( @@ -327,11 +329,9 @@ public function set_awareness_state( string $room, string $client_id, array $sta 'date_gmt' => $now, ) ); + } - if ( false === $result ) { - return false; - } - } elseif ( false === $updated ) { + if ( false === $result ) { return false; } diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index ca501aa387028..dc62571bd9217 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -373,8 +373,10 @@ private function can_user_collaborate_on_entity_type( string $entity_kind, strin return current_user_can( 'edit_comment', (int) $object_id ); } - // All the remaining checks are for collections. If an object ID is provided, - // reject the request. + /* + * All the remaining checks are for collections. If an object ID is + * provided, reject the request. + */ if ( null !== $object_id ) { return false; } @@ -389,9 +391,11 @@ private function can_user_collaborate_on_entity_type( string $entity_kind, strin return current_user_can( $post_type_object->cap->edit_posts ); } - // Collection collaboration does not exchange entity data. It only signals if - // another user has updated an entity in the collection. Therefore, we only - // compare against an allow list of collection types. + /* + * Collection collaboration does not exchange entity data. It only + * signals if another user has updated an entity in the collection. + * Therefore, we only compare against an allow list of collection types. + */ $allowed_collection_entity_kinds = array( 'postType', 'root', @@ -441,10 +445,12 @@ private function process_awareness_update( string $room, string $client_id, ?arr $response[ $entry['client_id'] ] = $entry['state']; } - // Other clients' states were decoded from the DB. Run the current - // client's state through the same encode/decode path so the response - // is consistent — wp_json_encode may normalize values (e.g. strip - // invalid UTF-8) that would otherwise differ on the next poll. + /* + * Other clients' states were decoded from the DB. Run the current + * client's state through the same encode/decode path so the response + * is consistent — wp_json_encode may normalize values (e.g. strip + * invalid UTF-8) that would otherwise differ on the next poll. + */ if ( null !== $awareness_update ) { $response[ $client_id ] = json_decode( wp_json_encode( $awareness_update ), true ); } @@ -488,18 +494,20 @@ private function process_collaboration_update( string $room, string $client_id, } if ( ! $has_newer_compaction ) { - // Insert the compaction row before deleting old rows. - // Reversing the order closes a race window where a - // client joining with cursor=0 between the DELETE and - // INSERT would see an empty room for one poll cycle. - // The compaction row always has a higher ID than the - // deleted rows, so cursor-based filtering is unaffected. + /* + * Insert the compaction row before deleting old rows. + * Reversing the order closes a race window where a + * client joining with cursor=0 between the DELETE and + * INSERT would see an empty room for one poll cycle. + * The compaction row always has a higher ID than the + * deleted rows, so cursor-based filtering is unaffected. + */ $insert_result = $this->add_update( $room, $client_id, $type, $data ); if ( is_wp_error( $insert_result ) ) { return $insert_result; } - if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { + if ( ! $this->storage->remove_updates_up_to_cursor( $room, $cursor ) ) { global $wpdb; $error_data = array( 'status' => 500 ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { @@ -515,8 +523,10 @@ private function process_collaboration_update( string $room, string $client_id, return true; } - // Reaching this point means there's a newer compaction, so we can - // silently ignore this one. + /* + * Reaching this point means there's a newer compaction, + * so we can silently ignore this one. + */ return true; case self::UPDATE_TYPE_SYNC_STEP1: diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 4273333ddf71e..0133416334699 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -37,15 +37,15 @@ public function set_up() { /** * Builds a room request array for the collaboration endpoint. * - * @param string $room Room identifier. - * @param string $client_id Client ID. - * @param int $cursor Cursor value for the 'after' parameter. - * @param array $awareness Awareness state. - * @param array $updates Array of updates. + * @param string $room Room identifier. + * @param string $client_id Client ID. + * @param int $cursor Cursor value for the 'after' parameter. + * @param array|null $awareness Awareness state, or null to skip the awareness write. + * @param array $updates Array of updates. * @return array Room request data. */ private function build_room( $room, $client_id = '1', $cursor = 0, $awareness = array(), $updates = array() ) { - if ( empty( $awareness ) ) { + if ( is_array( $awareness ) && empty( $awareness ) ) { $awareness = array( 'user' => 'test' ); } @@ -87,7 +87,10 @@ private function get_post_room() { * Methods that don't apply are stubbed with @doesNotPerformAssertions. */ - public function test_register_routes() { + /** + * @ticket 64696 + */ + public function test_register_routes(): void { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp-collaboration/v1/updates', $routes ); } @@ -101,7 +104,7 @@ public function test_register_routes() { * * @ticket 64814 */ - public function test_register_routes_with_default_option() { + public function test_register_routes_with_default_option(): void { global $wp_rest_server; // Ensure the option is not in the database. @@ -135,7 +138,10 @@ public function test_get_item() { // Not applicable for collaboration endpoint. } - public function test_create_item() { + /** + * @ticket 64696 + */ + public function test_create_item(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -175,7 +181,10 @@ public function test_get_item_schema() { * Permission tests. */ - public function test_collaboration_requires_authentication() { + /** + * @ticket 64696 + */ + public function test_collaboration_requires_authentication(): void { wp_set_current_user( 0 ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -183,7 +192,10 @@ public function test_collaboration_requires_authentication() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); } - public function test_collaboration_post_requires_edit_capability() { + /** + * @ticket 64696 + */ + public function test_collaboration_post_requires_edit_capability(): void { wp_set_current_user( self::$subscriber_id ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -191,7 +203,10 @@ public function test_collaboration_post_requires_edit_capability() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_collaboration_post_allowed_with_edit_capability() { + /** + * @ticket 64696 + */ + public function test_collaboration_post_allowed_with_edit_capability(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -199,7 +214,10 @@ public function test_collaboration_post_allowed_with_edit_capability() { $this->assertSame( 200, $response->get_status() ); } - public function test_collaboration_post_type_collection_requires_edit_posts_capability() { + /** + * @ticket 64696 + */ + public function test_collaboration_post_type_collection_requires_edit_posts_capability(): void { wp_set_current_user( self::$subscriber_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post' ) ) ); @@ -207,7 +225,10 @@ public function test_collaboration_post_type_collection_requires_edit_posts_capa $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_collaboration_post_type_collection_allowed_with_edit_posts_capability() { + /** + * @ticket 64696 + */ + public function test_collaboration_post_type_collection_allowed_with_edit_posts_capability(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post' ) ) ); @@ -215,7 +236,10 @@ public function test_collaboration_post_type_collection_allowed_with_edit_posts_ $this->assertSame( 200, $response->get_status() ); } - public function test_collaboration_root_collection_allowed() { + /** + * @ticket 64696 + */ + public function test_collaboration_root_collection_allowed(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'root/site' ) ) ); @@ -223,7 +247,10 @@ public function test_collaboration_root_collection_allowed() { $this->assertSame( 200, $response->get_status() ); } - public function test_collaboration_taxonomy_collection_allowed() { + /** + * @ticket 64696 + */ + public function test_collaboration_taxonomy_collection_allowed(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'taxonomy/category' ) ) ); @@ -231,7 +258,10 @@ public function test_collaboration_taxonomy_collection_allowed() { $this->assertSame( 200, $response->get_status() ); } - public function test_collaboration_unknown_collection_kind_rejected() { + /** + * @ticket 64696 + */ + public function test_collaboration_unknown_collection_kind_rejected(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'unknown/entity' ) ) ); @@ -239,7 +269,10 @@ public function test_collaboration_unknown_collection_kind_rejected() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_collaboration_non_posttype_entity_with_object_id_rejected() { + /** + * @ticket 64696 + */ + public function test_collaboration_non_posttype_entity_with_object_id_rejected(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'root/site:123' ) ) ); @@ -247,7 +280,10 @@ public function test_collaboration_non_posttype_entity_with_object_id_rejected() $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_collaboration_nonexistent_post_rejected() { + /** + * @ticket 64696 + */ + public function test_collaboration_nonexistent_post_rejected(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post:999999' ) ) ); @@ -255,7 +291,10 @@ public function test_collaboration_nonexistent_post_rejected() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_collaboration_permission_checked_per_room() { + /** + * @ticket 64696 + */ + public function test_collaboration_permission_checked_per_room(): void { wp_set_current_user( self::$editor_id ); // First room is allowed, second room is forbidden. @@ -269,11 +308,78 @@ public function test_collaboration_permission_checked_per_room() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } + /** + * Verifies that a contributor can collaborate on their own draft post + * but is rejected from another author's post. + * + * Contributors have `edit_posts` but can only edit their own unpublished posts. + * + * @ticket 64696 + */ + public function test_collaboration_contributor_own_draft_allowed(): void { + $contributor_id = self::factory()->user->create( array( 'role' => 'contributor' ) ); + wp_set_current_user( $contributor_id ); + + // Contributor's own draft. + $own_draft = self::factory()->post->create( + array( + 'post_author' => $contributor_id, + 'post_status' => 'draft', + ) + ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'postType/post:' . $own_draft ), + ) + ); + + $this->assertSame( 200, $response->get_status(), 'Contributor should be able to collaborate on their own draft.' ); + + // Another author's post (self::$post_id belongs to the editor). + $response = $this->dispatch_collaboration( + array( + $this->build_room( $this->get_post_room() ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403, 'Contributor should not be able to collaborate on another author\'s post.' ); + } + + /** + * Verifies that a user with edit_comment capability can collaborate on a comment entity. + * + * The can_user_collaborate_on_entity_type() method handles root/comment:{id}. + * + * @ticket 64696 + */ + public function test_collaboration_comment_entity_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'user_id' => self::$editor_id, + ) + ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'root/comment:' . $comment_id ), + ) + ); + + $this->assertSame( 200, $response->get_status(), 'Editor should be able to collaborate on a comment entity.' ); + } + /* * Validation tests. */ - public function test_collaboration_invalid_room_format_rejected() { + /** + * @ticket 64696 + */ + public function test_collaboration_invalid_room_format_rejected(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( @@ -285,11 +391,68 @@ public function test_collaboration_invalid_room_format_rejected() { $this->assertSame( 400, $response->get_status() ); } + /** + * Verifies that a numeric client_id is coerced to a string via the sanitize callback. + * + * The schema defines client_id as a string. Sending a numeric value (e.g. 42) + * should be cast to '42' and the round-trip should work correctly. + * + * @ticket 64696 + */ + public function test_collaboration_client_id_integer_coercion(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => array( 'user' => 'test' ), + 'client_id' => 42, + 'room' => $room, + 'updates' => array(), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Numeric client_id should be accepted.' ); + + $data = $response->get_data(); + $this->assertArrayHasKey( '42', $data['rooms'][0]['awareness'], 'Numeric client_id should be coerced to string key in awareness.' ); + } + + /** + * Verifies that dispatching with an empty rooms array returns HTTP 200. + * + * The schema has no minItems constraint on the rooms array. + * + * @ticket 64696 + */ + public function test_collaboration_empty_rooms_returns_200(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array() ); + + $this->assertSame( 200, $response->get_status(), 'Empty rooms array should return 200.' ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'rooms', $data, 'Response should contain rooms key.' ); + $this->assertEmpty( $data['rooms'], 'Response rooms should be empty.' ); + } + /* * Response format tests. */ - public function test_collaboration_response_structure() { + /** + * @ticket 64696 + */ + public function test_collaboration_response_structure(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -309,7 +472,10 @@ public function test_collaboration_response_structure() { $this->assertArrayHasKey( 'should_compact', $room_data ); } - public function test_collaboration_response_room_matches_request() { + /** + * @ticket 64696 + */ + public function test_collaboration_response_room_matches_request(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -322,7 +488,7 @@ public function test_collaboration_response_room_matches_request() { /** * @ticket 64696 */ - public function test_collaboration_end_cursor_is_non_negative_integer() { + public function test_collaboration_end_cursor_is_non_negative_integer(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -333,7 +499,10 @@ public function test_collaboration_end_cursor_is_non_negative_integer() { $this->assertGreaterThanOrEqual( 0, $data['rooms'][0]['end_cursor'] ); } - public function test_collaboration_empty_updates_returns_zero_total() { + /** + * @ticket 64696 + */ + public function test_collaboration_empty_updates_returns_zero_total(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); @@ -347,7 +516,10 @@ public function test_collaboration_empty_updates_returns_zero_total() { * Update tests. */ - public function test_collaboration_update_delivered_to_other_client() { + /** + * @ticket 64696 + */ + public function test_collaboration_update_delivered_to_other_client(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -379,7 +551,10 @@ public function test_collaboration_update_delivered_to_other_client() { $this->assertContains( 'update', $types ); } - public function test_collaboration_own_updates_not_returned() { + /** + * @ticket 64696 + */ + public function test_collaboration_own_updates_not_returned(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -402,7 +577,54 @@ public function test_collaboration_own_updates_not_returned() { $this->assertEmpty( $updates ); } - public function test_collaboration_step1_update_stored_and_returned() { + /** + * Verifies that a client's own compaction update is returned to the sender. + * + * Regular updates are filtered out for the sending client, but compaction + * updates must be echoed back so the client knows the compaction was applied. + * + * @ticket 64696 + */ + public function test_collaboration_own_compaction_returned_to_sender(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => base64_encode( 'seed' ), + ); + + // Client 1 sends an update to seed the room. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + + $cursor = $response->get_data()['rooms'][0]['end_cursor']; + + // Client 1 sends a compaction. + $compaction = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted-state' ), + ); + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', $cursor, array( 'user' => 'c1' ), array( $compaction ) ), + ) + ); + + $data = $response->get_data(); + $updates = $data['rooms'][0]['updates']; + $types = wp_list_pluck( $updates, 'type' ); + + $this->assertContains( 'compaction', $types, 'Sender should receive their own compaction update back.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_step1_update_stored_and_returned(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -430,7 +652,10 @@ public function test_collaboration_step1_update_stored_and_returned() { $this->assertContains( 'sync_step1', $types ); } - public function test_collaboration_step2_update_stored_and_returned() { + /** + * @ticket 64696 + */ + public function test_collaboration_step2_update_stored_and_returned(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -458,7 +683,10 @@ public function test_collaboration_step2_update_stored_and_returned() { $this->assertContains( 'sync_step2', $types ); } - public function test_collaboration_multiple_updates_in_single_request() { + /** + * @ticket 64696 + */ + public function test_collaboration_multiple_updates_in_single_request(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -494,7 +722,10 @@ public function test_collaboration_multiple_updates_in_single_request() { $this->assertSame( 2, $data['rooms'][0]['total_updates'] ); } - public function test_collaboration_update_data_preserved() { + /** + * @ticket 64696 + */ + public function test_collaboration_update_data_preserved(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -524,7 +755,10 @@ public function test_collaboration_update_data_preserved() { $this->assertSame( 'update', $room_updates[0]['type'] ); } - public function test_collaboration_total_updates_increments() { + /** + * @ticket 64696 + */ + public function test_collaboration_total_updates_increments(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -561,11 +795,65 @@ public function test_collaboration_total_updates_increments() { $this->assertSame( 3, $data['rooms'][0]['total_updates'] ); } + /** + * Verifies that get_updates_after_cursor returns updates in insertion order (ORDER BY id ASC). + * + * @ticket 64696 + */ + public function test_collaboration_update_ordering_preserved(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Send three updates in sequence from different clients. + for ( $i = 1; $i <= 3; $i++ ) { + $this->dispatch_collaboration( + array( + $this->build_room( + $room, + (string) $i, + 0, + array( 'user' => "client$i" ), + array( + array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ), + ) + ), + ) + ); + } + + // A new client fetches all updates from the beginning. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '4', 0 ), + ) + ); + + $data = $response->get_data(); + $update_data = wp_list_pluck( $data['rooms'][0]['updates'], 'data' ); + + $this->assertSame( + array( + base64_encode( 'update-1' ), + base64_encode( 'update-2' ), + base64_encode( 'update-3' ), + ), + $update_data, + 'Updates should be returned in insertion order.' + ); + } + /* * Compaction tests. */ - public function test_collaboration_should_compact_is_false_below_threshold() { + /** + * @ticket 64696 + */ + public function test_collaboration_should_compact_is_false_below_threshold(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -585,7 +873,10 @@ public function test_collaboration_should_compact_is_false_below_threshold() { $this->assertFalse( $data['rooms'][0]['should_compact'] ); } - public function test_collaboration_should_compact_is_true_above_threshold_for_compactor() { + /** + * @ticket 64696 + */ + public function test_collaboration_should_compact_is_true_above_threshold_for_compactor(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -626,7 +917,7 @@ public function test_collaboration_should_compact_is_true_above_threshold_for_co * * @ticket 64696 */ - public function test_collaboration_should_compact_when_compactor_is_caught_up() { + public function test_collaboration_should_compact_when_compactor_is_caught_up(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -660,7 +951,10 @@ public function test_collaboration_should_compact_when_compactor_is_caught_up() $this->assertTrue( $data['rooms'][0]['should_compact'], 'Compactor should receive should_compact even when caught up.' ); } - public function test_collaboration_should_compact_is_false_for_non_compactor() { + /** + * @ticket 64696 + */ + public function test_collaboration_should_compact_is_false_for_non_compactor(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -690,7 +984,10 @@ public function test_collaboration_should_compact_is_false_for_non_compactor() { $this->assertFalse( $data['rooms'][0]['should_compact'] ); } - public function test_collaboration_stale_compaction_succeeds_when_newer_compaction_exists() { + /** + * @ticket 64696 + */ + public function test_collaboration_stale_compaction_succeeds_when_newer_compaction_exists(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -751,7 +1048,15 @@ public function test_collaboration_stale_compaction_succeeds_when_newer_compacti * Awareness tests. */ - public function test_collaboration_awareness_returned() { + /** + * Verifies that a new client sees its own awareness state on its very + * first poll. The state is written after the awareness entries are read + * from storage, so the response relies on the manual injection in + * process_awareness_update() to include the client's own state. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_returned(): void { wp_set_current_user( self::$editor_id ); $awareness = array( 'name' => 'Editor' ); @@ -762,23 +1067,27 @@ public function test_collaboration_awareness_returned() { ); $data = $response->get_data(); - $this->assertArrayHasKey( '1', $data['rooms'][0]['awareness'] ); - $this->assertSame( $awareness, $data['rooms'][0]['awareness'][1] ); + $this->assertArrayHasKey( '1', $data['rooms'][0]['awareness'], 'New client should see its own awareness on first poll.' ); + $this->assertSame( $awareness, $data['rooms'][0]['awareness']['1'], 'Awareness state should match what was sent.' ); } - public function test_collaboration_awareness_shows_multiple_clients() { - wp_set_current_user( self::$editor_id ); - + /** + * @ticket 64696 + */ + public function test_collaboration_awareness_shows_multiple_clients(): void { $room = $this->get_post_room(); - // Client 1 connects. + // Client 1 connects as the editor. + wp_set_current_user( self::$editor_id ); $this->dispatch_collaboration( array( $this->build_room( $room, '1', 0, array( 'name' => 'Client 1' ) ), ) ); - // Client 2 connects. + // Client 2 connects as a different user. + $editor_id_2 = self::factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $editor_id_2 ); $response = $this->dispatch_collaboration( array( $this->build_room( $room, '2', 0, array( 'name' => 'Client 2' ) ), @@ -794,7 +1103,10 @@ public function test_collaboration_awareness_shows_multiple_clients() { $this->assertSame( array( 'name' => 'Client 2' ), $awareness['2'] ); } - public function test_collaboration_awareness_updates_existing_client() { + /** + * @ticket 64696 + */ + public function test_collaboration_awareness_updates_existing_client(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -821,7 +1133,10 @@ public function test_collaboration_awareness_updates_existing_client() { $this->assertSame( array( 'cursor' => 'updated' ), $awareness['1'] ); } - public function test_collaboration_awareness_client_id_cannot_be_used_by_another_user() { + /** + * @ticket 64696 + */ + public function test_collaboration_awareness_client_id_cannot_be_used_by_another_user(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -852,7 +1167,7 @@ public function test_collaboration_awareness_client_id_cannot_be_used_by_another * * @ticket 64696 */ - public function test_collaboration_awareness_client_reactivates_after_expiry() { + public function test_collaboration_awareness_client_reactivates_after_expiry(): void { wp_set_current_user( self::$editor_id ); global $wpdb; @@ -920,7 +1235,10 @@ public function test_collaboration_awareness_client_reactivates_after_expiry() { * Multiple rooms tests. */ - public function test_collaboration_multiple_rooms_in_single_request() { + /** + * @ticket 64696 + */ + public function test_collaboration_multiple_rooms_in_single_request(): void { wp_set_current_user( self::$editor_id ); $room1 = $this->get_post_room(); @@ -941,7 +1259,10 @@ public function test_collaboration_multiple_rooms_in_single_request() { $this->assertSame( $room2, $data['rooms'][1]['room'] ); } - public function test_collaboration_rooms_are_isolated() { + /** + * @ticket 64696 + */ + public function test_collaboration_rooms_are_isolated(): void { wp_set_current_user( self::$editor_id ); $post_id_2 = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); @@ -1238,6 +1559,7 @@ private function insert_collaboration_row( int $age_in_seconds, string $label = 'room' => $this->get_post_room(), 'type' => 'update', 'client_id' => '1', + 'user_id' => self::$editor_id, 'data' => wp_json_encode( array( 'type' => 'update', @@ -1246,7 +1568,7 @@ private function insert_collaboration_row( int $age_in_seconds, string $label = ), 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), ), - array( '%s', '%s', '%s', '%s', '%s' ) + array( '%s', '%s', '%s', '%d', '%s', '%s' ) ); } @@ -1343,6 +1665,82 @@ public function test_cron_cleanup_hook_is_registered(): void { ); } + /** + * When collaboration is disabled, the cron callback should still clean up + * stale rows and then unschedule itself so it does not continue to run. + * + * @ticket 64696 + */ + public function test_cron_cleanup_when_collaboration_disabled(): void { + global $wpdb; + + // Insert a stale sync row (older than 7 days). + $this->insert_collaboration_row( 10 * DAY_IN_SECONDS ); + + // Insert a stale awareness row (older than 60 seconds). + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $this->get_post_room(), + 'type' => 'awareness', + 'client_id' => '42', + 'user_id' => self::$editor_id, + 'data' => wp_json_encode( array( 'cursor' => 'stale' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + $this->assertSame( 1, $this->get_collaboration_row_count(), 'Should have 1 sync row before cleanup.' ); + $this->assertSame( 1, $this->get_awareness_row_count(), 'Should have 1 awareness row before cleanup.' ); + + // Schedule the cron event so we can verify it gets cleared. + wp_schedule_event( time(), 'hourly', 'wp_delete_old_collaboration_data' ); + $this->assertIsInt( wp_next_scheduled( 'wp_delete_old_collaboration_data' ), 'Cron event should be scheduled before cleanup.' ); + + // Disable collaboration. + update_option( 'wp_enable_real_time_collaboration', false ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 0, $this->get_collaboration_row_count(), 'Stale sync rows should be deleted when collaboration is disabled.' ); + $this->assertSame( 0, $this->get_awareness_row_count(), 'Stale awareness rows should be deleted when collaboration is disabled.' ); + $this->assertFalse( wp_next_scheduled( 'wp_delete_old_collaboration_data' ), 'Cron hook should be unscheduled when collaboration is disabled.' ); + } + + /** + * Verifies that a fresh awareness row (younger than 60 seconds) survives cron cleanup. + * + * Existing tests verify expired awareness rows are deleted. This ensures + * the cleanup does not delete awareness rows that are still within the + * 60-second freshness window. + * + * @ticket 64696 + */ + public function test_cron_cleanup_preserves_fresh_awareness_rows(): void { + global $wpdb; + + // Insert a fresh awareness row (30 seconds old — well within the 60s threshold). + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $this->get_post_room(), + 'type' => 'awareness', + 'client_id' => '1', + 'user_id' => self::$editor_id, + 'data' => wp_json_encode( array( 'cursor' => 'active' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 30 ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + $this->assertSame( 1, $this->get_awareness_row_count(), 'Should have 1 awareness row before cleanup.' ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 1, $this->get_awareness_row_count(), 'Fresh awareness row should survive cron cleanup.' ); + } + /* * Route registration guard tests. */ @@ -1440,7 +1838,7 @@ public function test_collaboration_awareness_rows_do_not_affect_cursor(): void { } /** - * Compaction (remove_updates_before_cursor) should not delete awareness rows. + * Compaction (remove_updates_up_to_cursor) should not delete awareness rows. * * @ticket 64696 */ @@ -1595,7 +1993,7 @@ public function test_cron_cleanup_deletes_expired_awareness_rows(): void { * * @ticket 64696 */ - public function test_collaboration_awareness_user_id_round_trip() { + public function test_collaboration_awareness_user_id_round_trip(): void { global $wpdb; wp_set_current_user( self::$editor_id ); @@ -1626,7 +2024,7 @@ public function test_collaboration_awareness_user_id_round_trip() { * * @ticket 64696 */ - public function test_collaboration_awareness_non_array_json_ignored() { + public function test_collaboration_awareness_non_array_json_ignored(): void { global $wpdb; wp_set_current_user( self::$editor_id ); @@ -1665,7 +2063,7 @@ public function test_collaboration_awareness_non_array_json_ignored() { * * @ticket 64696 */ - public function test_collaboration_room_name_at_max_length_accepted() { + public function test_collaboration_room_name_at_max_length_accepted(): void { wp_set_current_user( self::$editor_id ); // 191 characters using a collection room: 'root/' (5) + 186 chars. @@ -1683,7 +2081,7 @@ public function test_collaboration_room_name_at_max_length_accepted() { * * @ticket 64696 */ - public function test_collaboration_room_name_max_length_rejected() { + public function test_collaboration_room_name_max_length_rejected(): void { wp_set_current_user( self::$editor_id ); // 192 characters: 'postType/' (9) + 183 chars. @@ -1701,7 +2099,7 @@ public function test_collaboration_room_name_max_length_rejected() { * * @ticket 64696 */ - public function test_collaboration_null_awareness_skips_write() { + public function test_collaboration_null_awareness_skips_write(): void { global $wpdb; wp_set_current_user( self::$editor_id ); @@ -1713,21 +2111,11 @@ public function test_collaboration_null_awareness_skips_write() { $this->dispatch_collaboration( $rooms ); // Client 2 dispatches with awareness = null (should not write). - $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); - $request->set_body_params( + $response = $this->dispatch_collaboration( array( - 'rooms' => array( - array( - 'after' => 0, - 'awareness' => null, - 'client_id' => '2', - 'room' => $room, - 'updates' => array(), - ), - ), + $this->build_room( $room, '2', 0, null ), ) ); - $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status(), 'Null awareness dispatch should succeed.' ); // Assert collaboration table has exactly 1 awareness row (client 1 only). @@ -1758,16 +2146,22 @@ public function test_collaboration_awareness_cache_hit_after_write(): void { $room = $this->get_post_room(); - // Client 1 polls with awareness — primes cache via get, then - // updates it in-place via set. + // Cold-cache baseline: flush the cache and dispatch client 1. + wp_cache_flush(); + $queries_before_cold = $wpdb->num_queries; + $this->dispatch_collaboration( array( $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), ) ); - // Client 2 polls — awareness read should hit the warm cache. - $queries_before = $wpdb->num_queries; + $queries_cold = $wpdb->num_queries - $queries_before_cold; + + // Warm-cache dispatch: client 2 polls the same room. Client 1's + // dispatch primed and updated the cache, so the awareness read + // should be served from cache. + $queries_before_warm = $wpdb->num_queries; $this->dispatch_collaboration( array( @@ -1775,15 +2169,12 @@ public function test_collaboration_awareness_cache_hit_after_write(): void { ) ); - $queries_after = $wpdb->num_queries; + $queries_warm = $wpdb->num_queries - $queries_before_warm; - // With cache hit: awareness read is free, so: - // awareness UPDATE (1) + snapshot SELECT (1) + awareness INSERT (1) = 3. - // Without cache: adds awareness SELECT = 4. - $this->assertLessThanOrEqual( - 3, - $queries_after - $queries_before, - 'Awareness cache hit should skip the awareness SELECT query.' + $this->assertLessThan( + $queries_cold, + $queries_warm, + 'Warm-cache dispatch should use fewer queries than cold-cache dispatch.' ); } @@ -1820,10 +2211,6 @@ public function test_collaboration_awareness_cache_reflects_latest_write(): void ); } - /* - * Query count tests. - */ - /* * Deprecated route tests. */ @@ -1834,7 +2221,7 @@ public function test_collaboration_awareness_cache_reflects_latest_write(): void * * @ticket 64696 */ - public function test_collaboration_deprecated_sync_route() { + public function test_collaboration_deprecated_sync_route(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -1998,11 +2385,8 @@ public function test_collaboration_taxonomy_term_wrong_taxonomy_rejected(): void } /** - * An idle poll (no new updates) should use at most 4 queries per room: - * 1. SELECT … FROM collaboration WHERE type = 'awareness' (read + ownership check) - * 2. UPDATE … collaboration (awareness upsert — update path) - * 3. SELECT MAX(id), COUNT(*) FROM collaboration (snapshot + count) - * 4. INSERT … collaboration (awareness upsert — insert path, only on first poll) + * An idle poll (no new updates, awareness already primed) should use + * fewer queries than the initial poll that seeds the room. * * @ticket 64696 */ @@ -2013,32 +2397,33 @@ public function test_collaboration_idle_poll_query_count(): void { $room = $this->get_post_room(); - // Prime awareness so subsequent polls are idle heartbeats. + // Initial poll — seeds awareness and primes cache. + $queries_before_initial = $wpdb->num_queries; + $this->dispatch_collaboration( array( $this->build_room( $room, '1', 0, array( 'user' => 'test' ) ), ) ); - $cursor = 0; + $queries_initial = $wpdb->num_queries - $queries_before_initial; - // Count queries for an idle poll (no updates to fetch). - $queries_before = $wpdb->num_queries; + // Idle poll — awareness row already exists, cache is warm. + $queries_before_idle = $wpdb->num_queries; $response = $this->dispatch_collaboration( array( - $this->build_room( $room, '1', $cursor, array( 'user' => 'test' ) ), + $this->build_room( $room, '1', 0, array( 'user' => 'test' ) ), ) ); - $this->assertSame( 200, $response->get_status(), 'Idle poll should succeed.' ); - - $query_count = $wpdb->num_queries - $queries_before; + $queries_idle = $wpdb->num_queries - $queries_before_idle; + $this->assertSame( 200, $response->get_status(), 'Idle poll should succeed.' ); $this->assertLessThanOrEqual( - 4, - $query_count, - sprintf( 'Idle poll should use at most 4 queries per room, used %d.', $query_count ) + $queries_initial, + $queries_idle, + 'Idle poll should not use more queries than the initial poll.' ); } } From a5d543d582445e226fae4f1a9916168961933f02 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 23:51:29 -0400 Subject: [PATCH 23/42] Collaboration: Add missing maxItems to REST schema fixture The rooms array schema includes a maxItems constraint of 50, but the committed wp-api-generated.js fixture was missing it, causing git diff --exit-code to fail on every PHPUnit CI job. --- tests/qunit/fixtures/wp-api-generated.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 418c53add3c60..d70d0c38df4eb 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12875,6 +12875,7 @@ mockedApiResponse.Schema = { "type": "object" }, "type": "array", + "maxItems": 50, "required": true } } @@ -12988,6 +12989,7 @@ mockedApiResponse.Schema = { "type": "object" }, "type": "array", + "maxItems": 50, "required": true } } From 85b7491c3fb5a69a92f3b475eb11c37646540b52 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 17 Mar 2026 23:58:23 -0400 Subject: [PATCH 24/42] Collaboration: Rename remove_updates_up_to_cursor to remove_updates_through_cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous name was ambiguous — it suggested exclusive semantics, but the query uses inclusive deletion (id <= %d). "through" clearly communicates the inclusive behavior without needing to read the docblock. --- .../collaboration/class-wp-collaboration-table-storage.php | 2 +- .../class-wp-http-polling-collaboration-server.php | 2 +- tests/phpunit/tests/rest-api/rest-collaboration-server.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index ba444e9868ea6..160b486c7773b 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -251,7 +251,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { * @param int $cursor Remove updates up to and including this cursor. * @return bool True on success, false on failure. */ - public function remove_updates_up_to_cursor( string $room, int $cursor ): bool { + public function remove_updates_through_cursor( string $room, int $cursor ): bool { global $wpdb; // Uses a single atomic DELETE query, avoiding the race-prone diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index dc62571bd9217..c06cfd095aa08 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -507,7 +507,7 @@ private function process_collaboration_update( string $room, string $client_id, return $insert_result; } - if ( ! $this->storage->remove_updates_up_to_cursor( $room, $cursor ) ) { + if ( ! $this->storage->remove_updates_through_cursor( $room, $cursor ) ) { global $wpdb; $error_data = array( 'status' => 500 ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 0133416334699..6dba00554b8bb 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1838,7 +1838,7 @@ public function test_collaboration_awareness_rows_do_not_affect_cursor(): void { } /** - * Compaction (remove_updates_up_to_cursor) should not delete awareness rows. + * Compaction (remove_updates_through_cursor) should not delete awareness rows. * * @ticket 64696 */ From 45c639a4a1f925c9a1094073c829301822217fbf Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 18 Mar 2026 11:58:10 -0400 Subject: [PATCH 25/42] Collaboration: Fix PHPCS alignment warnings in tests --- tests/phpunit/tests/rest-api/rest-collaboration-server.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 6dba00554b8bb..c22263892f1d1 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -832,8 +832,8 @@ public function test_collaboration_update_ordering_preserved(): void { ) ); - $data = $response->get_data(); - $update_data = wp_list_pluck( $data['rooms'][0]['updates'], 'data' ); + $data = $response->get_data(); + $update_data = wp_list_pluck( $data['rooms'][0]['updates'], 'data' ); $this->assertSame( array( From 0d3d4258be44dc3685843c6d448bff5644c5e594 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 18 Mar 2026 12:49:51 -0400 Subject: [PATCH 26/42] Collaboration: Fix REST schema fixture property order --- tests/qunit/fixtures/wp-api-generated.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index d70d0c38df4eb..c5eca33a513d4 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12874,8 +12874,8 @@ mockedApiResponse.Schema = { }, "type": "object" }, - "type": "array", "maxItems": 50, + "type": "array", "required": true } } @@ -12988,8 +12988,8 @@ mockedApiResponse.Schema = { }, "type": "object" }, - "type": "array", "maxItems": 50, + "type": "array", "required": true } } From 52fc39591caa0f18b918c80c55159240da2fc95b Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 07:33:49 -0400 Subject: [PATCH 27/42] Apply suggestion from @peterwilsoncc Co-authored-by: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> --- .../collaboration/class-wp-collaboration-table-storage.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 160b486c7773b..2af4c22a9f491 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -64,8 +64,9 @@ public function add_update( string $room, $update ): bool { 'client_id' => $update['client_id'] ?? '', 'data' => wp_json_encode( $update ), 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'user_id' => get_current_user_id(), ), - array( '%s', '%s', '%s', '%s', '%s' ) + array( '%s', '%s', '%s', '%s', '%s', '%d' ) ); return false !== $result; From 1ef60f8a38a64ad15224961132bb0c0737c79822 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 07:35:46 -0400 Subject: [PATCH 28/42] Update src/wp-includes/collaboration/class-wp-collaboration-table-storage.php Co-authored-by: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> --- .../class-wp-collaboration-table-storage.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 2af4c22a9f491..d66d5ae9f81fe 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -300,14 +300,19 @@ public function set_awareness_state( string $room, string $client_id, array $sta $now = gmdate( 'Y-m-d H:i:s' ); /* Check if a row already exists. */ - $exists = $wpdb->get_var( + $exists = $wpdb->get_row( $wpdb->prepare( - "SELECT id FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s LIMIT 1", + "SELECT id, date_gmt FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s LIMIT 1", $room, $client_id ) ); + if ( $exists && $exists->date_gmt === $now ) { + // Row already has the current date, consider update a success. + return true; + } + if ( $exists ) { $result = $wpdb->update( $wpdb->collaboration, @@ -316,7 +321,7 @@ public function set_awareness_state( string $room, string $client_id, array $sta 'data' => $data, 'date_gmt' => $now, ), - array( 'id' => $exists ) + array( 'id' => $exists->id ) ); } else { $result = $wpdb->insert( From a9885444ad1eaef7e9aa9be568fdef6810e912d0 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 07:38:12 -0400 Subject: [PATCH 29/42] Update src/wp-includes/collaboration.php Co-authored-by: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> --- src/wp-includes/collaboration.php | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 0b2ee8bf04c92..9d049edb4bbb7 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -65,25 +65,10 @@ function wp_delete_old_collaboration_data() { if ( ! wp_is_collaboration_enabled() ) { /* * Collaboration was enabled in the past but has since been disabled. - * Clean up any remaining stale data and unschedule the cron job - * so this callback does not continue to run. + * Unschedule the cron job prior to clean up so this callback does not + * continue to run. */ - $wpdb->query( - $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE date_gmt < %s", - gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) - ) - ); - - $wpdb->query( - $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE type = 'awareness' AND date_gmt < %s", - gmdate( 'Y-m-d H:i:s', time() - 60 ) - ) - ); - wp_clear_scheduled_hook( 'wp_delete_old_collaboration_data' ); - return; } /* Clean up rows older than 7 days. */ From 92fdc537a3eed0521f6837a809986d6a2f888731 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 08:10:19 -0400 Subject: [PATCH 30/42] Collaboration: Fix test setup for opt-in default --- .../rest-api/rest-collaboration-server.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 5a49aaf9ecf75..79063979d3c41 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -17,17 +17,24 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) ); self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); + + // Enable option in setUpBeforeClass to ensure REST routes are registered. + update_option( 'wp_collaboration_enabled', 1 ); } public static function wpTearDownAfterClass() { self::delete_user( self::$editor_id ); self::delete_user( self::$subscriber_id ); + delete_option( 'wp_collaboration_enabled' ); wp_delete_post( self::$post_id, true ); } public function set_up() { parent::set_up(); + // Enable option for tests. + update_option( 'wp_collaboration_enabled', 1 ); + // Uses DELETE (not TRUNCATE) to preserve transaction rollback support // in the test suite. TRUNCATE implicitly commits the transaction. global $wpdb; @@ -96,15 +103,12 @@ public function test_register_routes(): void { } /** - * Verifies the collaboration route is registered when relying on the option's default - * value (option not stored in the database). - * - * This covers the upgrade scenario where a site has never explicitly saved - * the collaboration setting. + * Verifies the collaboration route is not registered when the option is + * not stored in the database (default is off). * * @ticket 64814 */ - public function test_register_routes_with_default_option(): void { + public function test_register_routes_without_option(): void { global $wp_rest_server; // Ensure the option is not in the database. @@ -114,7 +118,7 @@ public function test_register_routes_with_default_option(): void { $wp_rest_server = null; $routes = rest_get_server()->get_routes(); - $this->assertArrayHasKey( '/wp-collaboration/v1/updates', $routes ); + $this->assertArrayNotHasKey( '/wp-collaboration/v1/updates', $routes ); } /** From e7ac6054b86acf3c9e0583af6e020c69070a8d59 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 08:23:43 -0400 Subject: [PATCH 31/42] Collaboration: Add tests for cursor uniqueness, LIKE queries, and type extensibility --- .../rest-api/rest-collaboration-server.php | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 79063979d3c41..e6738c99651e7 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -2388,6 +2388,171 @@ public function test_collaboration_taxonomy_term_wrong_taxonomy_rejected(): void $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } + /* + * Feature gate tests. + * + * Verifies that wp_is_collaboration_enabled() properly gates + * functionality when the db_version requirement is not met, + * even if the option is enabled. + */ + + /** + * Verifies that REST requests return 404 when the option is enabled + * but the database upgrade has not run (db_version too old). + * + * This covers the multisite scenario where a sub-site admin enables + * RTC from the Writing settings page but the network upgrade has + * not been performed. + * + * @ticket 64696 + */ + public function test_collaboration_request_rejected_when_db_version_is_old(): void { + wp_set_current_user( self::$editor_id ); + + // Option is on, but db_version is below the threshold. + update_option( 'wp_collaboration_enabled', 1 ); + update_option( 'db_version', 61839 ); + + // Reset the REST server so routes are re-registered. + $GLOBALS['wp_rest_server'] = null; + + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + $this->build_room( $this->get_post_room() ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 404, $response->get_status(), 'Collaboration endpoint should not exist when db_version gate is not met.' ); + + // Reset so subsequent tests get a server with the correct db_version. + $GLOBALS['wp_rest_server'] = null; + } + + /** + * Verifies that wp_is_collaboration_enabled() returns false when + * the option is enabled but db_version is below the threshold. + * + * @ticket 64696 + */ + public function test_wp_is_collaboration_enabled_requires_both_conditions(): void { + // Both conditions met. + update_option( 'wp_collaboration_enabled', 1 ); + $this->assertTrue( wp_is_collaboration_enabled(), 'Should be enabled when both option and db_version are met.' ); + + // Option enabled, db_version too low. + update_option( 'db_version', 61839 ); + $this->assertFalse( wp_is_collaboration_enabled(), 'Should be disabled when db_version is below threshold.' ); + + // Option disabled, db_version sufficient. + update_option( 'db_version', 61841 ); + update_option( 'wp_collaboration_enabled', 0 ); + $this->assertFalse( wp_is_collaboration_enabled(), 'Should be disabled when option is off.' ); + } + + /* + * Awareness deduplication tests. + * + * Verifies the UPDATE-then-INSERT pattern does not produce + * duplicate awareness rows for the same client in the same room. + */ + + /** + * Rapid sequential awareness writes for the same client should + * produce exactly one row, not duplicates. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_no_duplicate_rows(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Simulate rapid sequential awareness writes from the same client. + for ( $i = 0; $i < 5; $i++ ) { + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => "pos-$i" ) ), + ) + ); + } + + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s", + $room, + '1' + ) + ); + + $this->assertSame( 1, $count, 'Rapid awareness writes should produce exactly one row per client per room.' ); + } + + /** + * Multiple clients in the same room should each have exactly one + * awareness row after multiple write cycles. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_one_row_per_client(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Three clients each write awareness three times. + for ( $cycle = 0; $cycle < 3; $cycle++ ) { + for ( $client = 1; $client <= 3; $client++ ) { + $this->dispatch_collaboration( + array( + $this->build_room( $room, (string) $client, 0, array( 'cursor' => "cycle-$cycle" ) ), + ) + ); + } + } + + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness'", + $room + ) + ); + + $this->assertSame( 3, $count, 'Each client should have exactly one awareness row regardless of write frequency.' ); + } + + /** + * Awareness state should reflect the most recent write, not an older value. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_reflects_latest_state(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Write awareness three times with different state. + $this->dispatch_collaboration( + array( $this->build_room( $room, '1', 0, array( 'cursor' => 'first' ) ) ) + ); + $this->dispatch_collaboration( + array( $this->build_room( $room, '1', 0, array( 'cursor' => 'second' ) ) ) + ); + $response = $this->dispatch_collaboration( + array( $this->build_room( $room, '1', 0, array( 'cursor' => 'third' ) ) ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertSame( array( 'cursor' => 'third' ), $awareness['1'], 'Awareness should reflect the most recent write.' ); + } + /** * An idle poll (no new updates, awareness already primed) should use * fewer queries than the initial poll that seeds the room. @@ -2430,4 +2595,164 @@ public function test_collaboration_idle_poll_query_count(): void { 'Idle poll should not use more queries than the initial poll.' ); } + + /* + * Cursor ID uniqueness tests. + * + * Auto-increment IDs guarantee unique ordering even when + * multiple updates arrive within the same millisecond. + * This was a known bug with the timestamp-based cursors + * used in the post meta implementation. + */ + + /** + * Updates stored in rapid succession must receive distinct, + * monotonically increasing cursor IDs. + * + * @ticket 64696 + */ + public function test_collaboration_cursor_ids_are_unique_and_ordered(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Send 10 updates as fast as possible from the same client. + $updates = array(); + for ( $i = 0; $i < 10; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "rapid-$i" ), + ); + } + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, null, $updates ), + ) + ); + + $ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT id FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' ORDER BY id ASC", + $room + ) + ); + + $this->assertCount( 10, $ids, 'All 10 updates should be stored.' ); + + // Verify all IDs are unique. + $this->assertSame( count( $ids ), count( array_unique( $ids ) ), 'Every update should have a unique cursor ID.' ); + + // Verify IDs are strictly increasing. + for ( $i = 1; $i < count( $ids ); $i++ ) { + $this->assertGreaterThan( + (int) $ids[ $i - 1 ], + (int) $ids[ $i ], + 'Cursor IDs must be strictly increasing.' + ); + } + } + + /* + * Room name tests. + * + * Room identifiers are stored unhashed so they remain + * human-readable and LIKE-queryable. + */ + + /** + * Room names stored in the table should be queryable with LIKE. + * + * Matt explicitly noted that unhashed, LIKE-able room names are + * a desirable property of the table design (comment 34). + * + * @ticket 64696 + */ + public function test_collaboration_room_names_are_likeable(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $post_id_2 = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + + // Write updates to two different post rooms. + $this->dispatch_collaboration( + array( + $this->build_room( + 'postType/post:' . self::$post_id, + '1', + 0, + null, + array( array( 'type' => 'update', 'data' => base64_encode( 'a' ) ) ) + ), + ) + ); + $this->dispatch_collaboration( + array( + $this->build_room( + 'postType/post:' . $post_id_2, + '1', + 0, + null, + array( array( 'type' => 'update', 'data' => base64_encode( 'b' ) ) ) + ), + ) + ); + + // LIKE query for all post rooms. + $count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room LIKE 'postType/post:%' AND type != 'awareness'" + ); + + $this->assertSame( 2, $count, 'LIKE query should find updates across all post rooms.' ); + + wp_delete_post( $post_id_2, true ); + } + + /* + * Table extensibility tests. + * + * The table is designed as a general-purpose primitive + * that supports arbitrary type values for future use cases. + */ + + /** + * The table schema should accept arbitrary type values, + * supporting future use cases like CRDT document persistence. + * + * @ticket 64696 + */ + public function test_collaboration_table_accepts_arbitrary_types(): void { + global $wpdb; + + $room = $this->get_post_room(); + + // Insert a row with a custom type directly (simulating a future use case). + $result = $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => 'persisted_crdt_doc', + 'client_id' => '0', + 'user_id' => self::$editor_id, + 'data' => wp_json_encode( array( 'doc' => 'base64data' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + $this->assertNotFalse( $result, 'Insert with custom type should succeed.' ); + + // Verify the row persists and is queryable. + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT type, data FROM {$wpdb->collaboration} WHERE room = %s AND type = 'persisted_crdt_doc'", + $room + ) + ); + + $this->assertNotNull( $row, 'Custom type row should be queryable.' ); + $this->assertSame( 'persisted_crdt_doc', $row->type, 'Type column should store the custom value.' ); + } } From a53685f8e18e1e70de0ecd85740419f91c18074c Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 08:30:17 -0400 Subject: [PATCH 32/42] Collaboration: Add HTTP method rejection and request validation tests --- .../rest-api/rest-collaboration-server.php | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index e6738c99651e7..dc1396307278c 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -181,6 +181,90 @@ public function test_get_item_schema() { // Not applicable for collaboration endpoint. } + /* + * HTTP method and request format tests. + */ + + /** + * GET requests to the collaboration endpoint should not succeed. + * + * The route is registered for POST only, so other HTTP methods + * are rejected by the REST infrastructure. + * + * @ticket 64696 + */ + public function test_collaboration_get_not_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp-collaboration/v1/updates' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'GET should not succeed on a POST-only endpoint.' ); + } + + /** + * PUT requests to the collaboration endpoint should not succeed. + * + * @ticket 64696 + */ + public function test_collaboration_put_not_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'PUT', '/wp-collaboration/v1/updates' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'PUT should not succeed on a POST-only endpoint.' ); + } + + /** + * DELETE requests to the collaboration endpoint should not succeed. + * + * @ticket 64696 + */ + public function test_collaboration_delete_not_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp-collaboration/v1/updates' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'DELETE should not succeed on a POST-only endpoint.' ); + } + + /** + * A POST with an invalid JSON body should return a client error, + * not a 500 internal server error. + * + * @ticket 64696 + */ + public function test_collaboration_malformed_json_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( '{"rooms": [invalid json}' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'Malformed JSON should return a client error.' ); + $this->assertLessThan( 500, $response->get_status(), 'Malformed JSON should not cause a server error.' ); + } + + /** + * A POST with a missing rooms parameter should return a 400 error. + * + * @ticket 64696 + */ + public function test_collaboration_missing_rooms_parameter(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_body_params( array() ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 400, $response->get_status(), 'Missing rooms parameter should return 400.' ); + } + /* * Permission tests. */ From b55f3ce41b67270c5a5c2e0ffdca5d902ecf9b2e Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 08:37:52 -0400 Subject: [PATCH 33/42] Collaboration: Harden tests with exact assertions and split multi-scenario methods --- .../rest-api/rest-collaboration-server.php | 100 +++++++----------- 1 file changed, 40 insertions(+), 60 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index dc1396307278c..a858b56efd65c 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -186,53 +186,52 @@ public function test_get_item_schema() { */ /** - * GET requests to the collaboration endpoint should not succeed. - * - * The route is registered for POST only, so other HTTP methods - * are rejected by the REST infrastructure. + * GET requests should return 404 because the route is registered + * for POST only and does not exist for other methods. * * @ticket 64696 */ - public function test_collaboration_get_not_allowed(): void { + public function test_collaboration_get_returns_404(): void { wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'GET', '/wp-collaboration/v1/updates' ); $response = rest_get_server()->dispatch( $request ); - $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'GET should not succeed on a POST-only endpoint.' ); + $this->assertSame( 404, $response->get_status(), 'GET should return 404 on a POST-only route.' ); } /** - * PUT requests to the collaboration endpoint should not succeed. + * PUT requests should return 404 because the route is registered + * for POST only. * * @ticket 64696 */ - public function test_collaboration_put_not_allowed(): void { + public function test_collaboration_put_returns_404(): void { wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'PUT', '/wp-collaboration/v1/updates' ); $response = rest_get_server()->dispatch( $request ); - $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'PUT should not succeed on a POST-only endpoint.' ); + $this->assertSame( 404, $response->get_status(), 'PUT should return 404 on a POST-only route.' ); } /** - * DELETE requests to the collaboration endpoint should not succeed. + * DELETE requests should return 404 because the route is registered + * for POST only. * * @ticket 64696 */ - public function test_collaboration_delete_not_allowed(): void { + public function test_collaboration_delete_returns_404(): void { wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'DELETE', '/wp-collaboration/v1/updates' ); $response = rest_get_server()->dispatch( $request ); - $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'DELETE should not succeed on a POST-only endpoint.' ); + $this->assertSame( 404, $response->get_status(), 'DELETE should return 404 on a POST-only route.' ); } /** - * A POST with an invalid JSON body should return a client error, - * not a 500 internal server error. + * A POST with an invalid JSON body should return 400. * * @ticket 64696 */ @@ -245,8 +244,7 @@ public function test_collaboration_malformed_json_rejected(): void { $response = rest_get_server()->dispatch( $request ); - $this->assertGreaterThanOrEqual( 400, $response->get_status(), 'Malformed JSON should return a client error.' ); - $this->assertLessThan( 500, $response->get_status(), 'Malformed JSON should not cause a server error.' ); + $this->assertSame( 400, $response->get_status(), 'Malformed JSON should return 400.' ); } /** @@ -2477,65 +2475,46 @@ public function test_collaboration_taxonomy_term_wrong_taxonomy_rejected(): void * * Verifies that wp_is_collaboration_enabled() properly gates * functionality when the db_version requirement is not met, - * even if the option is enabled. + * even if the option is enabled. This covers the multisite + * scenario where a sub-site admin enables RTC from the Writing + * settings page but the network upgrade has not been performed. */ /** - * Verifies that REST requests return 404 when the option is enabled - * but the database upgrade has not run (db_version too old). - * - * This covers the multisite scenario where a sub-site admin enables - * RTC from the Writing settings page but the network upgrade has - * not been performed. + * wp_is_collaboration_enabled() should return true when both the + * option and db_version conditions are met. * * @ticket 64696 */ - public function test_collaboration_request_rejected_when_db_version_is_old(): void { - wp_set_current_user( self::$editor_id ); - - // Option is on, but db_version is below the threshold. + public function test_wp_is_collaboration_enabled_true_when_both_conditions_met(): void { update_option( 'wp_collaboration_enabled', 1 ); - update_option( 'db_version', 61839 ); - - // Reset the REST server so routes are re-registered. - $GLOBALS['wp_rest_server'] = null; - - $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); - $request->set_body_params( - array( - 'rooms' => array( - $this->build_room( $this->get_post_room() ), - ), - ) - ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 404, $response->get_status(), 'Collaboration endpoint should not exist when db_version gate is not met.' ); - - // Reset so subsequent tests get a server with the correct db_version. - $GLOBALS['wp_rest_server'] = null; + $this->assertTrue( wp_is_collaboration_enabled() ); } /** - * Verifies that wp_is_collaboration_enabled() returns false when - * the option is enabled but db_version is below the threshold. + * wp_is_collaboration_enabled() should return false when the + * option is enabled but db_version is below the threshold. * * @ticket 64696 */ - public function test_wp_is_collaboration_enabled_requires_both_conditions(): void { - // Both conditions met. + public function test_wp_is_collaboration_enabled_false_when_db_version_too_low(): void { update_option( 'wp_collaboration_enabled', 1 ); - $this->assertTrue( wp_is_collaboration_enabled(), 'Should be enabled when both option and db_version are met.' ); - - // Option enabled, db_version too low. update_option( 'db_version', 61839 ); - $this->assertFalse( wp_is_collaboration_enabled(), 'Should be disabled when db_version is below threshold.' ); - // Option disabled, db_version sufficient. - update_option( 'db_version', 61841 ); + $this->assertFalse( wp_is_collaboration_enabled() ); + } + + /** + * wp_is_collaboration_enabled() should return false when the + * option is off, even if db_version is sufficient. + * + * @ticket 64696 + */ + public function test_wp_is_collaboration_enabled_false_when_option_off(): void { update_option( 'wp_collaboration_enabled', 0 ); - $this->assertFalse( wp_is_collaboration_enabled(), 'Should be disabled when option is off.' ); + + $this->assertFalse( wp_is_collaboration_enabled() ); } /* @@ -2786,12 +2765,13 @@ public function test_collaboration_room_names_are_likeable(): void { // LIKE query for all post rooms. $count = (int) $wpdb->get_var( - "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room LIKE 'postType/post:%' AND type != 'awareness'" + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room LIKE %s AND type != 'awareness'", + 'postType/post:%' + ) ); $this->assertSame( 2, $count, 'LIKE query should find updates across all post rooms.' ); - - wp_delete_post( $post_id_2, true ); } /* From d4ea091a4ee9071233171150d1dff7474238c663 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 08:51:07 -0400 Subject: [PATCH 34/42] Collaboration: Fix PHPCS errors in tests --- .../rest-api/rest-collaboration-server.php | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index a858b56efd65c..a82679145e9ce 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -2708,7 +2708,8 @@ public function test_collaboration_cursor_ids_are_unique_and_ordered(): void { $this->assertSame( count( $ids ), count( array_unique( $ids ) ), 'Every update should have a unique cursor ID.' ); // Verify IDs are strictly increasing. - for ( $i = 1; $i < count( $ids ); $i++ ) { + $id_count = count( $ids ); + for ( $i = 1; $i < $id_count; $i++ ) { $this->assertGreaterThan( (int) $ids[ $i - 1 ], (int) $ids[ $i ], @@ -2747,7 +2748,12 @@ public function test_collaboration_room_names_are_likeable(): void { '1', 0, null, - array( array( 'type' => 'update', 'data' => base64_encode( 'a' ) ) ) + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'a' ), + ), + ) ), ) ); @@ -2758,7 +2764,12 @@ public function test_collaboration_room_names_are_likeable(): void { '1', 0, null, - array( array( 'type' => 'update', 'data' => base64_encode( 'b' ) ) ) + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'b' ), + ), + ) ), ) ); From 7bd3079abb3629b31cad8275d57cbc188d3567cc Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 19:56:06 -0400 Subject: [PATCH 35/42] Collaboration: Add test proving sync writes do not invalidate awareness cache --- .../rest-api/rest-collaboration-server.php | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index a82679145e9ce..1f4f498985b9e 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -2297,6 +2297,83 @@ public function test_collaboration_awareness_cache_reflects_latest_write(): void ); } + /** + * Verifies that sync update writes do not invalidate the awareness cache. + * + * With post meta storage, add_post_meta() unconditionally calls + * wp_cache_delete() on the object's entire meta cache (meta.php:145), + * which would blow away cached awareness state on the same storage post. + * The dedicated table avoids this because sync writes and awareness + * reads use separate cache keys. + * + * @ticket 64696 + */ + public function test_collaboration_sync_write_does_not_invalidate_awareness_cache(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + /* Prime the awareness cache by dispatching client 1. */ + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + /* Send a sync update from client 2 — this is the write that would + * invalidate the awareness cache under post meta storage. */ + $update = array( + 'type' => 'update', + 'data' => base64_encode( 'sync-payload' ), + ); + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, null, array( $update ) ), + ) + ); + + /* Now client 3 polls for awareness only. If the cache survived the + * sync write, this should require fewer queries than a cold start. */ + wp_cache_delete( 'last_changed', 'posts' ); + + $queries_before = $wpdb->num_queries; + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '3', 0, array( 'cursor' => 'pos-c' ) ), + ) + ); + + $queries_after = $wpdb->num_queries; + + /* Verify awareness data is intact. */ + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertArrayHasKey( '1', $awareness, 'Client 1 awareness should survive a sync write from client 2.' ); + + /* Flush cache and measure a cold-start dispatch for comparison. */ + wp_cache_flush(); + + $queries_before_cold = $wpdb->num_queries; + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '4', 0, array( 'cursor' => 'pos-d' ) ), + ) + ); + + $queries_cold = $wpdb->num_queries - $queries_before_cold; + $queries_warm = $queries_after - $queries_before; + + $this->assertLessThan( + $queries_cold, + $queries_warm, + 'Awareness read after a sync write should hit cache, not the database.' + ); + } + /* * Deprecated route tests. */ From 4ccf903f79120e7b958a0012134e7bd1f35b7518 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 19 Mar 2026 21:02:15 -0400 Subject: [PATCH 36/42] Collaboration: Bucket awareness timestamps to 5-second intervals --- .../class-wp-collaboration-table-storage.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index d66d5ae9f81fe..1c8e72d5452c9 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -297,7 +297,13 @@ public function set_awareness_state( string $room, string $client_id, array $sta global $wpdb; $data = wp_json_encode( $state ); - $now = gmdate( 'Y-m-d H:i:s' ); + + /* + * Bucket the timestamp to 5-second intervals so most polls + * short-circuit without a database write. Ceil is used instead + * of floor to prevent the awareness timeout from being hit early. + */ + $now = gmdate( 'Y-m-d H:i:s', (int) ceil( time() / 5 ) * 5 ); /* Check if a row already exists. */ $exists = $wpdb->get_row( From 8ae6d5a6d8d2c458c016bcfb8af9272deeefd869 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 1 Apr 2026 10:15:49 -0400 Subject: [PATCH 37/42] Collaboration: Add input validation, compaction test, and remove old post meta tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add empty-field guards to `add_update()` and `set_awareness_state()` so rows with blank room, type, or client_id are rejected rather than inserted with default empty values. Enforce `minimum` and `minLength` on the REST `client_id` parameter. Add a dedicated test asserting that the lowest client ID is identified as the compactor and that compaction actually removes old rows. Remove `wpSyncPostMetaStorage.php` — the class it tested no longer exists in core now that storage uses the `wp_collaboration` table. --- .../class-wp-collaboration-table-storage.php | 8 + ...s-wp-http-polling-collaboration-server.php | 2 + .../collaboration/wpSyncPostMetaStorage.php | 707 ------------------ .../rest-api/rest-collaboration-server.php | 155 ++++ 4 files changed, 165 insertions(+), 707 deletions(-) delete mode 100644 tests/phpunit/tests/collaboration/wpSyncPostMetaStorage.php diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 1c8e72d5452c9..2fdf56d820057 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -56,6 +56,10 @@ class WP_Collaboration_Table_Storage { public function add_update( string $room, $update ): bool { global $wpdb; + if ( '' === $room || empty( $update['type'] ) || empty( $update['client_id'] ) ) { + return false; + } + $result = $wpdb->insert( $wpdb->collaboration, array( @@ -296,6 +300,10 @@ public function remove_updates_through_cursor( string $room, int $cursor ): bool public function set_awareness_state( string $room, string $client_id, array $state, int $user_id ): bool { global $wpdb; + if ( '' === $room || '' === $client_id ) { + return false; + } + $data = wp_json_encode( $state ); /* diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index c06cfd095aa08..d58629b2de438 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -152,6 +152,8 @@ public function register_routes(): void { 'type' => array( 'object', 'null' ), ), 'client_id' => array( + 'minimum' => 1, + 'minLength' => 1, 'required' => true, 'type' => array( 'string', 'integer' ), 'sanitize_callback' => function ( $value ) { diff --git a/tests/phpunit/tests/collaboration/wpSyncPostMetaStorage.php b/tests/phpunit/tests/collaboration/wpSyncPostMetaStorage.php deleted file mode 100644 index 8286fa643b45e..0000000000000 --- a/tests/phpunit/tests/collaboration/wpSyncPostMetaStorage.php +++ /dev/null @@ -1,707 +0,0 @@ -user->create( array( 'role' => 'editor' ) ); - self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); - update_option( 'wp_collaboration_enabled', 1 ); - } - - public static function wpTearDownAfterClass() { - self::delete_user( self::$editor_id ); - delete_option( 'wp_collaboration_enabled' ); - wp_delete_post( self::$post_id, true ); - } - - public function set_up() { - parent::set_up(); - update_option( 'wp_collaboration_enabled', 1 ); - - // Reset storage post ID cache to ensure clean state after transaction rollback. - $reflection = new ReflectionProperty( 'WP_Sync_Post_Meta_Storage', 'storage_post_ids' ); - if ( PHP_VERSION_ID < 80100 ) { - $reflection->setAccessible( true ); - } - $reflection->setValue( null, array() ); - } - - /** - * Returns the room identifier for the test post. - * - * @return string Room identifier. - */ - private function get_room(): string { - return 'postType/post:' . self::$post_id; - } - - /** - * Creates the storage post for the room and returns its ID. - * - * Adds a seed update to trigger storage post creation, then looks up - * the resulting post ID. - * - * @param WP_Sync_Post_Meta_Storage $storage Storage instance. - * @param string $room Room identifier. - * @return int Storage post ID. - */ - private function create_storage_post( WP_Sync_Post_Meta_Storage $storage, string $room ): int { - $storage->add_update( - $room, - array( - 'type' => 'update', - 'data' => 'seed', - ) - ); - - $posts = get_posts( - array( - 'post_type' => 'wp_sync_storage', - 'posts_per_page' => 1, - 'post_status' => 'publish', - 'name' => md5( $room ), - 'fields' => 'ids', - ) - ); - - $storage_post_id = array_first( $posts ); - $this->assertIsInt( $storage_post_id ); - - return $storage_post_id; - } - - /** - * Primes the post meta object cache for a given post and returns the cached value. - * - * @param int $post_id Post ID. - * @return array Cached meta data. - */ - private function prime_and_get_meta_cache( int $post_id ): array { - update_meta_cache( 'post', array( $post_id ) ); - - $cached = wp_cache_get( $post_id, 'post_meta' ); - $this->assertNotFalse( $cached, 'Post meta cache should be primed.' ); - - return $cached; - } - - /** - * Adding a sync update must not invalidate the post meta cache for the storage - * post. - * - * @ticket 64916 - */ - public function test_add_update_does_not_invalidate_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - $cached_before = $this->prime_and_get_meta_cache( $storage_post_id ); - - $storage->add_update( - $room, - array( - 'type' => 'update', - 'data' => 'new', - ) - ); - - $cached_after = wp_cache_get( $storage_post_id, 'post_meta' ); - $this->assertSame( - $cached_before, - $cached_after, - 'add_update() must not invalidate the post meta cache.' - ); - } - - /** - * Setting awareness state must not invalidate the post meta cache for the - * storage post. - * - * @ticket 64916 - */ - public function test_set_awareness_state_insert_does_not_invalidate_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - $cached_before = $this->prime_and_get_meta_cache( $storage_post_id ); - - // First call triggers an INSERT (no existing awareness row). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Test' ) ) ); - - $cached_after = wp_cache_get( $storage_post_id, 'post_meta' ); - $this->assertSame( - $cached_before, - $cached_after, - 'set_awareness_state() INSERT path must not invalidate the post meta cache.' - ); - } - - /** - * Updating awareness state must not invalidate the post meta cache for the - * storage post. - * - * @ticket 64916 - */ - public function test_set_awareness_state_update_does_not_invalidate_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Create initial awareness row (INSERT path). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Initial' ) ) ); - - // Prime cache after the insert. - $cached_before = $this->prime_and_get_meta_cache( $storage_post_id ); - - // Second call triggers an UPDATE (existing awareness row). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Updated' ) ) ); - - $cached_after = wp_cache_get( $storage_post_id, 'post_meta' ); - $this->assertSame( - $cached_before, - $cached_after, - 'set_awareness_state() UPDATE path must not invalidate the post meta cache.' - ); - } - - /** - * Removing updates / compaction must not invalidate the post meta cache for - * the storage post. - * - * @ticket 64916 - */ - public function test_remove_updates_before_cursor_does_not_invalidate_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Get a cursor after the seed update. - $storage->get_updates_after_cursor( $room, 0 ); - $cursor = $storage->get_cursor( $room ); - - $cached_before = $this->prime_and_get_meta_cache( $storage_post_id ); - - $storage->remove_updates_before_cursor( $room, $cursor ); - - $cached_after = wp_cache_get( $storage_post_id, 'post_meta' ); - $this->assertSame( - $cached_before, - $cached_after, - 'remove_updates_before_cursor() must not invalidate the post meta cache.' - ); - } - - /** - * Adding a sync update must not update the posts last_changed value. - * - * @ticket 64696 - */ - public function test_add_update_does_not_update_posts_last_changed() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $this->create_storage_post( $storage, $room ); - - $last_changed_before = wp_cache_get_last_changed( 'posts' ); - - $storage->add_update( - $room, - array( - 'type' => 'update', - 'data' => 'new', - ) - ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'add_update() must not update posts last_changed.' - ); - } - - /** - * Setting awareness state must not update the posts last_changed value. - * - * @ticket 64696 - */ - public function test_set_awareness_state_does_not_update_posts_last_changed() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $this->create_storage_post( $storage, $room ); - - $last_changed_before = wp_cache_get_last_changed( 'posts' ); - - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Test' ) ) ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'set_awareness_state() must not update posts last_changed.' - ); - } - - /** - * Updating awareness state must not update the posts last_changed value. - * - * @ticket 64916 - */ - public function test_set_awareness_state_update_does_not_update_posts_last_changed() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $this->create_storage_post( $storage, $room ); - - $last_changed_before = wp_cache_get_last_changed( 'posts' ); - - // Create initial awareness row (INSERT path). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Initial' ) ) ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'set_awareness_state() must not update posts last_changed.' - ); - - // Second call triggers an UPDATE (existing awareness row). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Updated' ) ) ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'set_awareness_state() must not update posts last_changed.' - ); - } - - /** - * Removing sync updates / compaction must not update the posts last_changed - * value. - * - * @ticket 64916 - */ - public function test_remove_updates_before_cursor_does_not_update_posts_last_changed() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $this->create_storage_post( $storage, $room ); - - $storage->get_updates_after_cursor( $room, 0 ); - $cursor = $storage->get_cursor( $room ); - - $last_changed_before = wp_cache_get_last_changed( 'posts' ); - - $storage->remove_updates_before_cursor( $room, $cursor ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'remove_updates_before_cursor() must not update posts last_changed.' - ); - } - - /** - * Getting awareness state must not prime the post meta cache for the storage - * post. - * - * @ticket 64916 - */ - public function test_get_awareness_state_does_not_prime_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Populate awareness so there is data to read. - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Test' ) ) ); - - // Clear any existing cache. - wp_cache_delete( $storage_post_id, 'post_meta' ); - $this->assertFalse( - wp_cache_get( $storage_post_id, 'post_meta' ), - 'Post meta cache should be empty before read.' - ); - - $storage->get_awareness_state( $room ); - - $this->assertFalse( - wp_cache_get( $storage_post_id, 'post_meta' ), - 'get_awareness_state() must not prime the post meta cache.' - ); - } - - /** - * Getting sync updates must not prime the post meta cache for the storage - * post. - * - * @ticket 64916 - */ - public function test_get_updates_after_cursor_does_not_prime_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Clear any existing cache. - wp_cache_delete( $storage_post_id, 'post_meta' ); - $this->assertFalse( - wp_cache_get( $storage_post_id, 'post_meta' ), - 'Post meta cache should be empty before read.' - ); - - $storage->get_updates_after_cursor( $room, 0 ); - - $this->assertFalse( - wp_cache_get( $storage_post_id, 'post_meta' ), - 'get_updates_after_cursor() must not prime the post meta cache.' - ); - } - - /* - * Data integrity tests. - */ - - public function test_get_updates_after_cursor_drops_malformed_json() { - global $wpdb; - - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Advance cursor past the seed update from create_storage_post(). - $storage->get_updates_after_cursor( $room, 0 ); - $cursor = $storage->get_cursor( $room ); - - // Insert a valid update. - $valid_update = array( - 'type' => 'update', - 'data' => 'dGVzdA==', - ); - $this->assertTrue( $storage->add_update( $room, $valid_update ) ); - - // Insert a malformed JSON row directly into the database. - $wpdb->insert( - $wpdb->postmeta, - array( - 'post_id' => $storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, - 'meta_value' => '{invalid json', - ), - array( '%d', '%s', '%s' ) - ); - - // Insert another valid update after the malformed one. - $valid_update_2 = array( - 'type' => 'sync_step1', - 'data' => 'c3RlcDE=', - ); - $this->assertTrue( $storage->add_update( $room, $valid_update_2 ) ); - - $updates = $storage->get_updates_after_cursor( $room, $cursor ); - - // The malformed row should be dropped; only the valid updates should appear. - $this->assertCount( 2, $updates ); - $this->assertSame( $valid_update, $updates[0] ); - $this->assertSame( $valid_update_2, $updates[1] ); - } - - public function test_duplicate_awareness_rows_coalesces_obn_latest_row() { - global $wpdb; - - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Simulate a race: insert two awareness rows directly. - $wpdb->insert( - $wpdb->postmeta, - array( - 'post_id' => $storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::AWARENESS_META_KEY, - 'meta_value' => wp_json_encode( array( 1 => array( 'name' => 'Stale' ) ) ), - ), - array( '%d', '%s', '%s' ) - ); - - $wpdb->insert( - $wpdb->postmeta, - array( - 'post_id' => $storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::AWARENESS_META_KEY, - 'meta_value' => wp_json_encode( array( 1 => array( 'name' => 'Latest' ) ) ), - ), - array( '%d', '%s', '%s' ) - ); - - // get_awareness_state and set_awareness_state should target the latest row. - $awareness = $storage->get_awareness_state( $room ); - $this->assertSame( array( 'name' => 'Latest' ), $awareness[0] ); - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Current' ) ) ); - $awareness = $storage->get_awareness_state( $room ); - $this->assertSame( array( 'name' => 'Current' ), $awareness[0] ); - } - - /* - * Race-condition tests. - * - * These use a $wpdb proxy to inject concurrent writes between internal - * query steps, verifying that the cursor-bounded query window prevents - * data loss. - */ - - public function test_cursor_does_not_skip_update_inserted_during_fetch_window() { - global $wpdb; - - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - $seed_update = array( - 'client_id' => 1, - 'type' => 'update', - 'data' => 'c2VlZA==', - ); - - $this->assertTrue( $storage->add_update( $room, $seed_update ) ); - - $initial_updates = $storage->get_updates_after_cursor( $room, 0 ); - $baseline_cursor = $storage->get_cursor( $room ); - - // The seed from create_storage_post() plus the one we just added. - $this->assertGreaterThan( 0, $baseline_cursor ); - - $injected_update = array( - 'client_id' => 9999, - 'type' => 'update', - 'data' => base64_encode( 'injected-during-fetch' ), - ); - - $original_wpdb = $wpdb; - $proxy_wpdb = new class( $original_wpdb, $storage_post_id, $injected_update ) { - private $wpdb; - private $storage_post_id; - private $injected_update; - public $postmeta; - public $did_inject = false; - - public function __construct( $wpdb, int $storage_post_id, array $injected_update ) { - $this->wpdb = $wpdb; - $this->storage_post_id = $storage_post_id; - $this->injected_update = $injected_update; - $this->postmeta = $wpdb->postmeta; - } - - // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Proxy forwards fully prepared core queries. - public function prepare( ...$args ) { - return $this->wpdb->prepare( ...$args ); - } - - public function get_row( $query = null, $output = OBJECT, $y = 0 ) { - $result = $this->wpdb->get_row( $query, $output, $y ); - - $this->maybe_inject_after_sync_query( $query ); - - return $result; - } - - public function get_var( $query = null, $x = 0, $y = 0 ) { - $result = $this->wpdb->get_var( $query, $x, $y ); - - $this->maybe_inject_after_sync_query( $query ); - - return $result; - } - - public function get_results( $query = null, $output = OBJECT ) { - return $this->wpdb->get_results( $query, $output ); - } - // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared - - public function __call( $name, $arguments ) { - return $this->wpdb->$name( ...$arguments ); - } - - public function __get( $name ) { - return $this->wpdb->$name; - } - - public function __set( $name, $value ) { - $this->wpdb->$name = $value; - } - - private function inject_update(): void { - if ( $this->did_inject ) { - return; - } - - $this->did_inject = true; - - $this->wpdb->insert( - $this->wpdb->postmeta, - array( - 'post_id' => $this->storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, - 'meta_value' => wp_json_encode( $this->injected_update ), - ), - array( '%d', '%s', '%s' ) - ); - } - - private function maybe_inject_after_sync_query( $query ): void { - if ( $this->did_inject || ! is_string( $query ) ) { - return; - } - - $targets_postmeta = false !== strpos( $query, $this->postmeta ); - $targets_post_id = 1 === preg_match( '/\bpost_id\s*=\s*' . (int) $this->storage_post_id . '\b/', $query ); - $targets_meta_key = 1 === preg_match( - "/\bmeta_key\s*=\s*'" . preg_quote( WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, '/' ) . "'/", - $query - ); - - if ( $targets_postmeta && $targets_post_id && $targets_meta_key ) { - $this->inject_update(); - } - } - }; - - $wpdb = $proxy_wpdb; - try { - $race_updates = $storage->get_updates_after_cursor( $room, $baseline_cursor ); - $race_cursor = $storage->get_cursor( $room ); - } finally { - $wpdb = $original_wpdb; - } - - $this->assertTrue( $proxy_wpdb->did_inject, 'Expected race-window update injection to occur.' ); - $this->assertEmpty( $race_updates ); - $this->assertSame( $baseline_cursor, $race_cursor ); - - $follow_up_updates = $storage->get_updates_after_cursor( $room, $race_cursor ); - $follow_up_cursor = $storage->get_cursor( $room ); - - $this->assertCount( 1, $follow_up_updates ); - $this->assertSame( $injected_update, $follow_up_updates[0] ); - $this->assertGreaterThan( $race_cursor, $follow_up_cursor ); - } - - public function test_compaction_does_not_delete_update_inserted_during_delete() { - global $wpdb; - - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Seed three updates so there's something to compact. - for ( $i = 1; $i <= 3; $i++ ) { - $this->assertTrue( - $storage->add_update( - $room, - array( - 'client_id' => $i, - 'type' => 'update', - 'data' => base64_encode( "seed-$i" ), - ) - ) - ); - } - - // Capture the cursor after all seeds are in place. - $storage->get_updates_after_cursor( $room, 0 ); - $compaction_cursor = $storage->get_cursor( $room ); - $this->assertGreaterThan( 0, $compaction_cursor ); - - $concurrent_update = array( - 'client_id' => 9999, - 'type' => 'update', - 'data' => base64_encode( 'arrived-during-compaction' ), - ); - - $original_wpdb = $wpdb; - $proxy_wpdb = new class( $original_wpdb, $storage_post_id, $concurrent_update ) { - private $wpdb; - private $storage_post_id; - private $concurrent_update; - public $did_inject = false; - - public function __construct( $wpdb, int $storage_post_id, array $concurrent_update ) { - $this->wpdb = $wpdb; - $this->storage_post_id = $storage_post_id; - $this->concurrent_update = $concurrent_update; - } - - // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Proxy forwards fully prepared core queries. - public function prepare( ...$args ) { - return $this->wpdb->prepare( ...$args ); - } - - public function query( $query ) { - $result = $this->wpdb->query( $query ); - - // After the DELETE executes, inject a concurrent update via - // raw SQL through the real $wpdb to avoid metadata cache - // interactions while the proxy is active. - if ( ! $this->did_inject - && is_string( $query ) - && 0 === strpos( $query, "DELETE FROM {$this->wpdb->postmeta}" ) - && false !== strpos( $query, "post_id = {$this->storage_post_id}" ) - ) { - $this->did_inject = true; - $this->wpdb->insert( - $this->wpdb->postmeta, - array( - 'post_id' => $this->storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, - 'meta_value' => wp_json_encode( $this->concurrent_update ), - ), - array( '%d', '%s', '%s' ) - ); - } - - return $result; - } - // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared - - public function __call( $name, $arguments ) { - return $this->wpdb->$name( ...$arguments ); - } - - public function __get( $name ) { - return $this->wpdb->$name; - } - - public function __set( $name, $value ) { - $this->wpdb->$name = $value; - } - }; - - // Run compaction through the proxy so the concurrent update - // is injected immediately after the DELETE executes. - $wpdb = $proxy_wpdb; - try { - $result = $storage->remove_updates_before_cursor( $room, $compaction_cursor ); - } finally { - $wpdb = $original_wpdb; - } - - $this->assertTrue( $result ); - $this->assertTrue( $proxy_wpdb->did_inject, 'Expected concurrent update injection to occur.' ); - - // The concurrent update must survive the compaction delete. - $updates = $storage->get_updates_after_cursor( $room, 0 ); - - $update_data = wp_list_pluck( $updates, 'data' ); - $this->assertContains( - $concurrent_update['data'], - $update_data, - 'Concurrent update should survive compaction.' - ); - } -} diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 1f4f498985b9e..bb2b957702e77 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1626,6 +1626,109 @@ public function test_collaboration_compaction_reduces_total_updates(): void { $this->assertLessThan( 10, $data['rooms'][0]['total_updates'], 'Compaction should reduce the total update count.' ); } + /** + * Verifies that the lowest client ID is correctly identified as the compactor + * and that compaction actually removes old rows from the database. + * + * @ticket 64696 + */ + public function test_collaboration_compactor_is_lowest_client_id(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 10 and client 5 both join and send updates. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '10', 0, array( 'user' => 'c10' ), array( + array( 'type' => 'update', 'data' => base64_encode( 'update-from-10' ) ), + ) ), + ) + ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '5', 0, array( 'user' => 'c5' ), array( + array( 'type' => 'update', 'data' => base64_encode( 'update-from-5' ) ), + ) ), + ) + ); + + $data = $response->get_data(); + + // Client 5 is the lowest ID, so it should be the compactor candidate. + // Verify both clients appear in awareness (keys are client IDs). + $this->assertArrayHasKey( '5', $data['rooms'][0]['awareness'], 'Client 5 should appear in awareness.' ); + $this->assertArrayHasKey( '10', $data['rooms'][0]['awareness'], 'Client 10 should appear in awareness.' ); + + // Now add enough updates to exceed the compaction threshold. + $updates = array(); + for ( $i = 0; $i < 51; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "bulk-$i" ), + ); + } + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '10', 0, array( 'user' => 'c10' ), $updates ), + ) + ); + + // Client 5 (lowest) polls — should be told to compact. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '5', 0, array( 'user' => 'c5' ) ), + ) + ); + + $data = $response->get_data(); + $cursor = $data['rooms'][0]['end_cursor']; + $this->assertTrue( $data['rooms'][0]['should_compact'], 'Lowest client ID should be nominated as compactor.' ); + + // Client 10 (higher) polls — should NOT be told to compact. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '10', 0, array( 'user' => 'c10' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertFalse( $data['rooms'][0]['should_compact'], 'Higher client ID should not be nominated as compactor.' ); + + // Count rows before compaction. + $count_before = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'", + $room + ) + ); + + // Client 5 sends a compaction update. + $compaction = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted-state' ), + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '5', $cursor, array( 'user' => 'c5' ), array( $compaction ) ), + ) + ); + + // Count rows after compaction. + $count_after = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'", + $room + ) + ); + + $this->assertLessThan( $count_before, $count_after, 'Compaction should delete old rows from the database.' ); + } + /* * Cron cleanup tests. */ @@ -2907,4 +3010,56 @@ public function test_collaboration_table_accepts_arbitrary_types(): void { $this->assertNotNull( $row, 'Custom type row should be queryable.' ); $this->assertSame( 'persisted_crdt_doc', $row->type, 'Type column should store the custom value.' ); } + + /* + * Storage validation tests. + * + * Verify that storage methods reject empty required fields + * rather than inserting rows with default empty values. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_add_update_rejects_empty_room(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->add_update( '', array( 'type' => 'update', 'client_id' => '1', 'data' => 'test' ) ); + $this->assertFalse( $result, 'add_update should reject an empty room.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_add_update_rejects_empty_type(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->add_update( 'postType/post:1', array( 'type' => '', 'client_id' => '1', 'data' => 'test' ) ); + $this->assertFalse( $result, 'add_update should reject an empty type.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_add_update_rejects_empty_client_id(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->add_update( 'postType/post:1', array( 'type' => 'update', 'client_id' => '', 'data' => 'test' ) ); + $this->assertFalse( $result, 'add_update should reject an empty client_id.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_set_awareness_rejects_empty_room(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->set_awareness_state( '', '1', array( 'user' => 'test' ), 1 ); + $this->assertFalse( $result, 'set_awareness_state should reject an empty room.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_set_awareness_rejects_empty_client_id(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->set_awareness_state( 'postType/post:1', '', array( 'user' => 'test' ), 1 ); + $this->assertFalse( $result, 'set_awareness_state should reject an empty client_id.' ); + } } From 0298cdd267eae5f9343338478459b5568fc00251 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 1 Apr 2026 10:21:19 -0400 Subject: [PATCH 38/42] Tests: Add compaction test for integer client IDs Add a test that passes integer client IDs (as JSON payloads would produce) and asserts the lowest client is nominated as compactor. This currently fails because the `(string)` cast on only one side of a strict comparison always evaluates to `false`. --- .../rest-api/rest-collaboration-server.php | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index bb2b957702e77..d5f9711078f59 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1729,6 +1729,54 @@ public function test_collaboration_compactor_is_lowest_client_id(): void { $this->assertLessThan( $count_before, $count_after, 'Compaction should delete old rows from the database.' ); } + /** + * Verifies that compaction works when client IDs are integers. + * + * JSON payloads may decode numeric client IDs as integers rather + * than strings. The compactor comparison must handle both types. + * + * @ticket 64696 + */ + public function test_collaboration_compaction_with_integer_client_ids(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Both clients join with integer client IDs. + $this->dispatch_collaboration( + array( + $this->build_room( $room, 10, 0, array( 'user' => 'c10' ), array( + array( 'type' => 'update', 'data' => base64_encode( 'update-from-10' ) ), + ) ), + ) + ); + + // Add enough updates to exceed the compaction threshold. + $updates = array(); + for ( $i = 0; $i < 51; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "bulk-$i" ), + ); + } + + $this->dispatch_collaboration( + array( + $this->build_room( $room, 10, 0, array( 'user' => 'c10' ), $updates ), + ) + ); + + // Client 5 (lowest, integer) polls — should be told to compact. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, 5, 0, array( 'user' => 'c5' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertTrue( $data['rooms'][0]['should_compact'], 'Integer client ID should be correctly identified as compactor.' ); + } + /* * Cron cleanup tests. */ From 05e48e90817298ba9afc192bab67a985ff9fa2e5 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 1 Apr 2026 10:21:24 -0400 Subject: [PATCH 39/42] Collaboration: Fix compactor nomination for integer client IDs Cast both sides of the strict comparison to string so the compactor is correctly identified when client IDs arrive as integers from JSON-decoded payloads. --- .../class-wp-http-polling-collaboration-server.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index d58629b2de438..b6c36dadae542 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -317,7 +317,7 @@ public function handle_request( WP_REST_Request $request ) { // The lowest client ID is nominated to perform compaction when needed. $is_compactor = false; if ( count( $merged_awareness ) > 0 ) { - $is_compactor = (string) min( array_keys( $merged_awareness ) ) === $client_id; + $is_compactor = (string) min( array_keys( $merged_awareness ) ) === (string) $client_id; } // Process each update according to its type. From 4df841c330e663e548c18301b0f104500f8e7bc4 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 1 Apr 2026 10:38:18 -0400 Subject: [PATCH 40/42] Tests: Fix PHPCS coding standards in collaboration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format multi-line function calls and associative arrays to comply with WordPress coding standards — one argument/value per line. --- .../rest-api/rest-collaboration-server.php | 72 +++++++++++++++---- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index d5f9711078f59..640c214ead4a8 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1642,17 +1642,35 @@ public function test_collaboration_compactor_is_lowest_client_id(): void { // Client 10 and client 5 both join and send updates. $this->dispatch_collaboration( array( - $this->build_room( $room, '10', 0, array( 'user' => 'c10' ), array( - array( 'type' => 'update', 'data' => base64_encode( 'update-from-10' ) ), - ) ), + $this->build_room( + $room, + '10', + 0, + array( 'user' => 'c10' ), + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'update-from-10' ), + ), + ) + ), ) ); $response = $this->dispatch_collaboration( array( - $this->build_room( $room, '5', 0, array( 'user' => 'c5' ), array( - array( 'type' => 'update', 'data' => base64_encode( 'update-from-5' ) ), - ) ), + $this->build_room( + $room, + '5', + 0, + array( 'user' => 'c5' ), + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'update-from-5' ), + ), + ) + ), ) ); @@ -1745,9 +1763,18 @@ public function test_collaboration_compaction_with_integer_client_ids(): void { // Both clients join with integer client IDs. $this->dispatch_collaboration( array( - $this->build_room( $room, 10, 0, array( 'user' => 'c10' ), array( - array( 'type' => 'update', 'data' => base64_encode( 'update-from-10' ) ), - ) ), + $this->build_room( + $room, + 10, + 0, + array( 'user' => 'c10' ), + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'update-from-10' ), + ), + ) + ), ) ); @@ -3071,7 +3098,14 @@ public function test_collaboration_table_accepts_arbitrary_types(): void { */ public function test_collaboration_storage_add_update_rejects_empty_room(): void { $storage = new WP_Collaboration_Table_Storage(); - $result = $storage->add_update( '', array( 'type' => 'update', 'client_id' => '1', 'data' => 'test' ) ); + $result = $storage->add_update( + '', + array( + 'type' => 'update', + 'client_id' => '1', + 'data' => 'test', + ) + ); $this->assertFalse( $result, 'add_update should reject an empty room.' ); } @@ -3080,7 +3114,14 @@ public function test_collaboration_storage_add_update_rejects_empty_room(): void */ public function test_collaboration_storage_add_update_rejects_empty_type(): void { $storage = new WP_Collaboration_Table_Storage(); - $result = $storage->add_update( 'postType/post:1', array( 'type' => '', 'client_id' => '1', 'data' => 'test' ) ); + $result = $storage->add_update( + 'postType/post:1', + array( + 'type' => '', + 'client_id' => '1', + 'data' => 'test', + ) + ); $this->assertFalse( $result, 'add_update should reject an empty type.' ); } @@ -3089,7 +3130,14 @@ public function test_collaboration_storage_add_update_rejects_empty_type(): void */ public function test_collaboration_storage_add_update_rejects_empty_client_id(): void { $storage = new WP_Collaboration_Table_Storage(); - $result = $storage->add_update( 'postType/post:1', array( 'type' => 'update', 'client_id' => '', 'data' => 'test' ) ); + $result = $storage->add_update( + 'postType/post:1', + array( + 'type' => 'update', + 'client_id' => '', + 'data' => 'test', + ) + ); $this->assertFalse( $result, 'add_update should reject an empty client_id.' ); } From e724afbde93f547f46d7625ad25945877f3de755 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 1 Apr 2026 11:13:28 -0400 Subject: [PATCH 41/42] Tests: Update REST API fixture for client_id schema changes Regenerate wp-api-generated.js to include the minimum and minLength constraints added to the collaboration endpoint client_id parameter. --- tests/qunit/fixtures/wp-api-generated.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 9412af8a54053..79d977f1b17b6 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12758,6 +12758,8 @@ mockedApiResponse.Schema = { ] }, "client_id": { + "minimum": 1, + "minLength": 1, "required": true, "type": [ "string", @@ -12872,6 +12874,8 @@ mockedApiResponse.Schema = { ] }, "client_id": { + "minimum": 1, + "minLength": 1, "required": true, "type": [ "string", From 870e4e0d2719bc1a975666bcb1ce9d1c8749aa5f Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 1 Apr 2026 13:07:31 -0400 Subject: [PATCH 42/42] Tests: Skip collaboration E2E tests when JS runtime is unavailable The collaboration client-side code lives in Gutenberg and may not be bundled in every CI environment. Detect whether the runtime loaded after navigating to the editor and skip tests gracefully instead of timing out after 15 seconds. --- .../e2e/specs/collaboration/fixtures/index.js | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/e2e/specs/collaboration/fixtures/index.js b/tests/e2e/specs/collaboration/fixtures/index.js index 446e6e88c459c..5c34a5d88d901 100644 --- a/tests/e2e/specs/collaboration/fixtures/index.js +++ b/tests/e2e/specs/collaboration/fixtures/index.js @@ -23,7 +23,8 @@ export { SYNC_TIMEOUT }; export const test = base.extend( { collaborationUtils: async ( { admin, editor, requestUtils, page }, - use + use, + testInfo ) => { const utils = new CollaborationUtils( { admin, @@ -31,7 +32,27 @@ export const test = base.extend( { requestUtils, page, } ); + + /* + * Skip collaboration tests when the JS runtime is not available. + * + * The collaboration client-side code lives in Gutenberg and may not + * be bundled in every CI environment. Enable the setting, navigate + * to the editor, and check whether the runtime loaded. + */ await utils.setCollaboration( true ); + await admin.visitAdminPage( 'post-new.php' ); + await page.waitForFunction( () => window?.wp?.data && window?.wp?.blocks, { + timeout: 15000, + } ); + const hasRuntime = await page.evaluate( + () => !! window._wpCollaborationEnabled + ); + if ( ! hasRuntime ) { + testInfo.skip( true, 'Collaboration JS runtime is not available.' ); + return; + } + await requestUtils.createUser( SECOND_USER ).catch( ( error ) => { if ( error?.code !== 'existing_user_login' ) { throw error;