From 6815ce1b54b794237a793aa06f422f0c872c72d9 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:08 -0400 Subject: [PATCH 01/12] 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 340bdebac71eb..64bff58d84293 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 0113e49d2a871..3ea2c526a046e 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 5fc8cacff06985933d4c4333e7725eb1a247641e Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:14 -0400 Subject: [PATCH 02/12] 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 9825cd914362b4b15101c1140493f23a26bf3a9a Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:21 -0400 Subject: [PATCH 03/12] 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 | 378 ------------------ .../interface-wp-sync-storage.php | 86 ---- src/wp-includes/post.php | 38 +- 3 files changed, 1 insertion(+), 501 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 658a9b65539dd..0000000000000 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ /dev/null @@ -1,378 +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 - * - * @global wpdb $wpdb WordPress database abstraction object. - * - * @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 { - global $wpdb; - - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - // Use direct database operation to avoid cache invalidation performed by - // post meta functions (`wp_cache_set_posts_last_changed()` and direct - // `wp_cache_delete()` calls). - return (bool) $wpdb->insert( - $wpdb->postmeta, - array( - 'post_id' => $post_id, - 'meta_key' => self::SYNC_UPDATE_META_KEY, - 'meta_value' => wp_json_encode( $update ), - ), - array( '%d', '%s', '%s' ) - ); - } - - /** - * Gets awareness state for a given room. - * - * @since 7.0.0 - * - * @global wpdb $wpdb WordPress database abstraction object. - * - * @param string $room Room identifier. - * @return array Awareness state. - */ - public function get_awareness_state( string $room ): array { - global $wpdb; - - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return array(); - } - - // Use direct database operation to avoid updating the post meta cache. - // ORDER BY meta_id DESC ensures the latest row wins if duplicates exist - // from a past race condition in set_awareness_state(). - $meta_value = $wpdb->get_var( - $wpdb->prepare( - "SELECT meta_value FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1", - $post_id, - self::AWARENESS_META_KEY - ) - ); - - if ( null === $meta_value ) { - return array(); - } - - $awareness = json_decode( $meta_value, true ); - - if ( ! is_array( $awareness ) ) { - return array(); - } - - return array_values( $awareness ); - } - - /** - * Sets awareness state for a given room. - * - * @since 7.0.0 - * - * @global wpdb $wpdb WordPress database abstraction object. - * - * @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 { - global $wpdb; - - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - // Use direct database operation to avoid cache invalidation performed by - // post meta functions (`wp_cache_set_posts_last_changed()` and direct - // `wp_cache_delete()` calls). - // - // If two concurrent requests both see no row and both INSERT, the - // duplicate is harmless: get_awareness_state() reads the latest row - // (ORDER BY meta_id DESC). - $meta_id = $wpdb->get_var( - $wpdb->prepare( - "SELECT meta_id FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1", - $post_id, - self::AWARENESS_META_KEY - ) - ); - - if ( $meta_id ) { - return (bool) $wpdb->update( - $wpdb->postmeta, - array( 'meta_value' => wp_json_encode( $awareness ) ), - array( 'meta_id' => $meta_id ), - array( '%s' ), - array( '%d' ) - ); - } - - return (bool) $wpdb->insert( - $wpdb->postmeta, - array( - 'post_id' => $post_id, - 'meta_key' => self::AWARENESS_META_KEY, - 'meta_value' => wp_json_encode( $awareness ), - ), - array( '%d', '%s', '%s' ) - ); - } - - /** - * Gets the current cursor for a given room. - * - * The cursor is set during get_updates_after_cursor() and represents the - * highest meta_id seen for the room's sync updates. - * - * @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', - 'orderby' => 'ID', - 'order' => 'ASC', - ) - ); - - $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 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 after the given cursor. - * - * @since 7.0.0 - * - * @global wpdb $wpdb WordPress database abstraction object. - * - * @param string $room Room identifier. - * @param int $cursor Return updates after this cursor (meta_id). - * @return array Sync updates. - */ - public function get_updates_after_cursor( string $room, int $cursor ): array { - global $wpdb; - - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - $this->room_cursors[ $room ] = 0; - $this->room_update_counts[ $room ] = 0; - return array(); - } - - // Capture the current room state first so the returned cursor is race-safe. - $stats = $wpdb->get_row( - $wpdb->prepare( - "SELECT COUNT(*) AS total_updates, COALESCE( MAX(meta_id), 0 ) AS max_meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s", - $post_id, - self::SYNC_UPDATE_META_KEY - ) - ); - - $total_updates = $stats ? (int) $stats->total_updates : 0; - $max_meta_id = $stats ? (int) $stats->max_meta_id : 0; - - $this->room_update_counts[ $room ] = $total_updates; - $this->room_cursors[ $room ] = $max_meta_id; - - if ( $max_meta_id <= $cursor ) { - return array(); - } - - $rows = $wpdb->get_results( - $wpdb->prepare( - "SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id > %d AND meta_id <= %d ORDER BY meta_id ASC", - $post_id, - self::SYNC_UPDATE_META_KEY, - $cursor, - $max_meta_id - ) - ); - - if ( ! $rows ) { - return array(); - } - - $updates = array(); - foreach ( $rows as $row ) { - $decoded = json_decode( $row->meta_value, true ); - if ( null !== $decoded ) { - $updates[] = $decoded; - } - } - - return $updates; - } - - /** - * Removes updates from a room that are older than the given cursor. - * - * @since 7.0.0 - * - * @global wpdb $wpdb WordPress database abstraction object. - * - * @param string $room Room identifier. - * @param int $cursor Remove updates with meta_id < this cursor. - * @return bool True on success, false on failure. - */ - public function remove_updates_before_cursor( string $room, int $cursor ): bool { - global $wpdb; - - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - $deleted_rows = $wpdb->query( - $wpdb->prepare( - "DELETE FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id < %d", - $post_id, - self::SYNC_UPDATE_META_KEY, - $cursor - ) - ); - - if ( false === $deleted_rows ) { - return false; - } - - return true; - } -} 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 215fa153f7495..837fba3358094 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -656,43 +656,7 @@ function create_initial_post_types() { 'supports' => array( 'title' ), ) ); - - if ( wp_is_collaboration_enabled() ) { - 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, - 'can_export' => false, - 'supports' => array( 'custom-fields' ), - ) - ); - } - + register_post_status( 'publish', array( From cb79a4d26f1a5f7d432edbb61421b7b985c6c23f Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:28 -0400 Subject: [PATCH 04/12] 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 | 33 +++++++++++++++++++++++++++++ src/wp-includes/default-filters.php | 1 + src/wp-includes/post.php | 2 +- src/wp-includes/rest-api.php | 6 +++--- src/wp-settings.php | 5 ++--- 6 files changed, 48 insertions(+), 7 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 11698a2ac78f4..cf51b15314815 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -83,3 +83,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 4b6d9de25fa11..17c1695e6d72d 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/post.php b/src/wp-includes/post.php index 837fba3358094..a51bf46e28652 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -656,7 +656,7 @@ function create_initial_post_types() { 'supports' => array( 'title' ), ) ); - + register_post_status( 'publish', array( diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index c524f9e22a12f..688b236556fdf 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -431,9 +431,9 @@ function create_initial_rest_routes() { // Collaboration. if ( wp_is_collaboration_enabled() ) { - $sync_storage = new WP_Sync_Post_Meta_Storage(); - $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); - $sync_server->register_routes(); + $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 d1258b8d2d9a839ec1a1728fd1be70ab8d3be06e Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:02:37 -0400 Subject: [PATCH 05/12] 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 | 11 +- .../tests/rest-api/rest-sync-server.php | 868 -------- tests/qunit/fixtures/wp-api-generated.js | 293 +++ 9 files changed, 3189 insertions(+), 869 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 9c6c431e5ef35..9bdc725671252 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 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; $wp_rest_server = new Spy_REST_Server(); @@ -203,6 +206,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 ); @@ -214,7 +221,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 7a04226ced8c9..0000000000000 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ /dev/null @@ -1,868 +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 ) ); - - // 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 ); - - // 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; - - // Ensure the option is not in the database. - delete_option( 'wp_collaboration_enabled' ); - - // Reset the REST server so routes are re-registered from scratch. - $wp_rest_server = null; - - $routes = rest_get_server()->get_routes(); - $this->assertArrayNotHasKey( '/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->assertGreaterThanOrEqual( 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 003dc397ae305..1bd328a6f503c 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -20,7 +20,13 @@ mockedApiResponse.Schema = { "wp/v2", "wp-site-health/v1", "wp-block-editor/v1", +<<<<<<< HEAD "wp-abilities/v1" +======= + "wp-abilities/v1", + "wp-collaboration/v1", + "wp-sync/v1" +>>>>>>> 886f0b1270 (Tests: Add collaboration server tests and remove legacy sync tests) ], "authentication": { "application-passwords": { @@ -3664,6 +3670,51 @@ mockedApiResponse.Schema = { } ] }, +<<<<<<< HEAD +======= + "/wp/v2/media/(?P[\\d]+)/sideload": { + "namespace": "wp/v2", + "methods": [ + "POST" + ], + "endpoints": [ + { + "methods": [ + "POST" + ], + "args": { + "id": { + "description": "Unique identifier for the attachment.", + "type": "integer", + "required": false + }, + "image_size": { + "description": "Image size.", + "type": "string", + "enum": [ + "thumbnail", + "medium", + "medium_large", + "large", + "1536x1536", + "2048x2048", + "original", + "full", + "scaled" + ], + "required": true + }, + "convert_format": { + "type": "boolean", + "default": true, + "description": "Whether to convert image formats.", + "required": false + } + } + } + ] + }, +>>>>>>> 886f0b1270 (Tests: Add collaboration server tests and remove legacy sync tests) "/wp/v2/menu-items": { "namespace": "wp/v2", "methods": [ @@ -11011,6 +11062,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.", @@ -12698,6 +12767,227 @@ mockedApiResponse.Schema = { } } ] +<<<<<<< HEAD +======= + }, + "/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": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "namespace": { + "default": "wp-sync/v1", + "required": false + }, + "context": { + "default": "view", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-sync/v1" + } + ] + } + }, + "/wp-sync/v1/updates": { + "namespace": "wp-sync/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-sync/v1/updates" + } + ] + } +>>>>>>> 886f0b1270 (Tests: Add collaboration server tests and remove legacy sync tests) } }, "site_logo": 0, @@ -14544,6 +14834,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 f484aafb96955793036b8a1d04b78c6265d07bef Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 13:13:45 -0400 Subject: [PATCH 06/12] 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 d0112ae5e9c5086d2174918f33f912ec49c2e0a1 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 14:39:14 -0400 Subject: [PATCH 07/12] 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 | 5 +++++ 2 files changed, 6 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 9bdc725671252..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' ); @@ -112,6 +115,8 @@ public function test_expected_routes_in_schema() { '/wp/v2/media/(?P[\\d]+)', '/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 4b51127d55d0f1d5ebbbfe118928dce02398a947 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 15:19:09 -0400 Subject: [PATCH 08/12] 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 | 68 ++++++++++++++---------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 1bd328a6f503c..4abf28fddb2d3 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3714,7 +3714,30 @@ mockedApiResponse.Schema = { } ] }, +<<<<<<< HEAD >>>>>>> 886f0b1270 (Tests: Add collaboration server tests and remove legacy sync tests) +======= + "/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 + } + } + } + ] + }, +>>>>>>> 09d0b86326 (Tests: Remove erroneous connector fixtures from merge artifact) "/wp/v2/menu-items": { "namespace": "wp/v2", "methods": [ @@ -11062,24 +11085,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.", @@ -12827,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", @@ -12936,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", @@ -14834,9 +14847,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 0350d6903fbaabc740a399d4bc83811233b728ff Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 22:09:47 -0400 Subject: [PATCH 09/12] 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 77f01a2af0f01a2c3588d9b5eb53896c7149a29a Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 16 Mar 2026 22:09:53 -0400 Subject: [PATCH 10/12] 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 | 46 ++++--------------------------- src/wp-includes/version.php | 2 +- 4 files changed, 10 insertions(+), 43 deletions(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 64bff58d84293..7f797eb0c705a 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 cf51b15314815..a9efe66451c9a 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -7,52 +7,18 @@ */ /** - * Determines whether real-time collaboration is enabled. + * Checks whether real-time collaboration is enabled. * - * If the WP_ALLOW_COLLABORATION constant is false, - * collaboration is always disabled regardless of the database option. - * Otherwise, falls back to the 'wp_collaboration_enabled' option. + * The feature requires both the site option and the database schema + * introduced in db_version 61841. * * @since 7.0.0 * - * @return bool Whether real-time collaboration is enabled. + * @return bool True if collaboration is enabled, false otherwise. */ function wp_is_collaboration_enabled() { - return ( - wp_is_collaboration_allowed() && - (bool) get_option( 'wp_collaboration_enabled' ) - ); -} - -/** - * Determines whether real-time collaboration is allowed. - * - * If the WP_ALLOW_COLLABORATION constant is false, - * collaboration is not allowed and cannot be enabled. - * The constant defaults to true, unless the WP_ALLOW_COLLABORATION - * environment variable is set to string "false". - * - * @since 7.0.0 - * - * @return bool Whether real-time collaboration is enabled. - */ -function wp_is_collaboration_allowed() { - if ( ! defined( 'WP_ALLOW_COLLABORATION' ) ) { - $env_value = getenv( 'WP_ALLOW_COLLABORATION' ); - if ( false === $env_value ) { - // Environment variable is not defined, default to allowing collaboration. - define( 'WP_ALLOW_COLLABORATION', true ); - } else { - /* - * Environment variable is defined, let's confirm it is actually set to - * "true" as it may still have a string value "false" – the preceeding - * `if` branch only tests for the boolean `false`. - */ - define( 'WP_ALLOW_COLLABORATION', 'true' === $env_value ); - } - } - - return WP_ALLOW_COLLABORATION; + return get_option( 'wp_enable_real_time_collaboration' ) + && get_option( 'db_version' ) >= 61841; } /** diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 3ea2c526a046e..0ff938f2dfadf 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 2ad18a08c82fd325f8ad2487fe97608f40e9f2a3 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:48:47 -0400 Subject: [PATCH 11/12] Update the generated API fixture. --- tests/qunit/fixtures/wp-api-generated.js | 75 ------------------------ 1 file changed, 75 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 4abf28fddb2d3..c837442d57e32 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -20,13 +20,9 @@ mockedApiResponse.Schema = { "wp/v2", "wp-site-health/v1", "wp-block-editor/v1", -<<<<<<< HEAD - "wp-abilities/v1" -======= "wp-abilities/v1", "wp-collaboration/v1", "wp-sync/v1" ->>>>>>> 886f0b1270 (Tests: Add collaboration server tests and remove legacy sync tests) ], "authentication": { "application-passwords": { @@ -3670,74 +3666,6 @@ mockedApiResponse.Schema = { } ] }, -<<<<<<< HEAD -======= - "/wp/v2/media/(?P[\\d]+)/sideload": { - "namespace": "wp/v2", - "methods": [ - "POST" - ], - "endpoints": [ - { - "methods": [ - "POST" - ], - "args": { - "id": { - "description": "Unique identifier for the attachment.", - "type": "integer", - "required": false - }, - "image_size": { - "description": "Image size.", - "type": "string", - "enum": [ - "thumbnail", - "medium", - "medium_large", - "large", - "1536x1536", - "2048x2048", - "original", - "full", - "scaled" - ], - "required": true - }, - "convert_format": { - "type": "boolean", - "default": true, - "description": "Whether to convert image formats.", - "required": false - } - } - } - ] - }, -<<<<<<< HEAD ->>>>>>> 886f0b1270 (Tests: Add collaboration server tests and remove legacy sync tests) -======= - "/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 - } - } - } - ] - }, ->>>>>>> 09d0b86326 (Tests: Remove erroneous connector fixtures from merge artifact) "/wp/v2/menu-items": { "namespace": "wp/v2", "methods": [ @@ -12772,8 +12700,6 @@ mockedApiResponse.Schema = { } } ] -<<<<<<< HEAD -======= }, "/wp-collaboration/v1": { "namespace": "wp-collaboration/v1", @@ -13000,7 +12926,6 @@ mockedApiResponse.Schema = { } ] } ->>>>>>> 886f0b1270 (Tests: Add collaboration server tests and remove legacy sync tests) } }, "site_logo": 0, From babf8c0546a188e95076f126ee1b285a070d260b Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Fri, 24 Apr 2026 01:15:40 -0400 Subject: [PATCH 12/12] Fix schema issues. --- tests/phpunit/tests/rest-api/rest-schema-setup.php | 2 -- tests/qunit/fixtures/wp-api-generated.js | 8 +------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 24b36a46a0fb3..e623803abdef0 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -115,8 +115,6 @@ public function test_expected_routes_in_schema() { '/wp/v2/media/(?P[\\d]+)', '/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', diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index c837442d57e32..a1a25ad14894b 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -24,13 +24,7 @@ mockedApiResponse.Schema = { "wp-collaboration/v1", "wp-sync/v1" ], - "authentication": { - "application-passwords": { - "endpoints": { - "authorization": "http://example.org/wp-admin/authorize-application.php" - } - } - }, + "authentication": [], "routes": { "/": { "namespace": "",