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-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 340bdebac71eb..7f797eb0c705a 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -186,6 +186,19 @@ 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', + 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"; // 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 914113bde00d0..dde5b44f0abbf 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 < 61841 ) { upgrade_700(); } diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index e5300e6d75122..f22ac010cc975 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.php b/src/wp-includes/collaboration.php index 11698a2ac78f4..350b6cf1b21b0 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -11,7 +11,8 @@ * * 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. + * Otherwise, the feature requires both the 'wp_collaboration_enabled' + * option and the database schema introduced in db_version 61841. * * @since 7.0.0 * @@ -20,7 +21,8 @@ function wp_is_collaboration_enabled() { return ( wp_is_collaboration_allowed() && - (bool) get_option( 'wp_collaboration_enabled' ) + get_option( 'wp_collaboration_enabled' ) && + get_option( 'db_version' ) >= 61841 ); } @@ -34,7 +36,7 @@ function wp_is_collaboration_enabled() { * * @since 7.0.0 * - * @return bool Whether real-time collaboration is enabled. + * @return bool Whether real-time collaboration is allowed. */ function wp_is_collaboration_allowed() { if ( ! defined( 'WP_ALLOW_COLLABORATION' ) ) { @@ -83,3 +85,41 @@ 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() { + global $wpdb; + + if ( ! wp_is_collaboration_enabled() ) { + /* + * Collaboration was enabled in the past but has since been disabled. + * Unschedule the cron job prior to clean up so this callback does not + * continue to run. + */ + wp_clear_scheduled_hook( 'wp_delete_old_collaboration_data' ); + } + + /* Clean up rows older than 7 days. */ + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE 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/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php new file mode 100644 index 0000000000000..2fdf56d820057 --- /dev/null +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -0,0 +1,392 @@ +, 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; + + if ( '' === $room || empty( $update['type'] ) || empty( $update['client_id'] ) ) { + return false; + } + + $result = $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => $update['type'] ?? '', + 'client_id' => $update['client_id'] ?? '', + 'data' => wp_json_encode( $update ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'user_id' => get_current_user_id(), + ), + array( '%s', '%s', '%s', '%s', '%s', '%d' ) + ); + + return false !== $result; + } + + /** + * Gets awareness state for a given room. + * + * 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 + * + * @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; + + $cache_key = 'awareness:' . str_replace( '/', ':', $room ); + $cached = wp_cache_get( $cache_key, 'collaboration' ); + + if ( false !== $cached ) { + return $cached; + } + + $cutoff = gmdate( 'Y-m-d H:i:s', time() - $timeout ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT client_id, user_id, data 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->data, true ); + if ( is_array( $decoded ) ) { + $entries[] = array( + 'client_id' => $row->client_id, + 'state' => $decoded, + 'user_id' => (int) $row->user_id, + ); + } + } + + wp_cache_set( $cache_key, $entries, 'collaboration', $timeout ); + + 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. + * + * @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; + + /* + * Uses a snapshot approach: captures MAX(id) and COUNT(*) in a single + * query, then fetches rows WHERE id > cursor AND id <= max_id. Updates + * arriving after the snapshot are deferred to the next poll, never lost. + * + * Only retrieves non-awareness rows — awareness rows are handled + * separately via get_awareness_state(). + */ + + /* Snapshot the current max ID and total row count in a single query. */ + $snapshot = $wpdb->get_row( + $wpdb->prepare( + "SELECT COALESCE( MAX( id ), 0 ) AS max_id, COUNT(*) AS total FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'", + $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 data 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->data, true ); + if ( is_array( $decoded ) ) { + $updates[] = $decoded; + } + } + + return $updates; + } + + /** + * Removes updates from a room up to and including the given cursor. + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param int $cursor Remove updates up to and including this cursor. + * @return bool True on success, false on failure. + */ + public function remove_updates_through_cursor( string $room, int $cursor ): bool { + global $wpdb; + + // Uses a single atomic DELETE query, avoiding the race-prone + // "delete all, re-add some" pattern. + $result = $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' AND id <= %d", + $room, + $cursor + ) + ); + + return false !== $result; + } + + /** + * Sets awareness state for a given client in a room. + * + * Uses SELECT-then-UPDATE/INSERT: checks for an existing row by + * primary key, then updates or inserts accordingly. Each client + * writes only its own row, eliminating the race condition inherent + * in shared-state approaches. + * + * After writing, the cached awareness entries for the room are updated + * in-place so that subsequent get_awareness_state() calls from other + * 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. + * + * @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; + + if ( '' === $room || '' === $client_id ) { + return false; + } + + $data = wp_json_encode( $state ); + + /* + * Bucket the timestamp to 5-second intervals so most polls + * short-circuit without a database write. Ceil is used instead + * of floor to prevent the awareness timeout from being hit early. + */ + $now = gmdate( 'Y-m-d H:i:s', (int) ceil( time() / 5 ) * 5 ); + + /* Check if a row already exists. */ + $exists = $wpdb->get_row( + $wpdb->prepare( + "SELECT id, date_gmt FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s LIMIT 1", + $room, + $client_id + ) + ); + + if ( $exists && $exists->date_gmt === $now ) { + // Row already has the current date, consider update a success. + return true; + } + + if ( $exists ) { + $result = $wpdb->update( + $wpdb->collaboration, + array( + 'user_id' => $user_id, + 'data' => $data, + 'date_gmt' => $now, + ), + array( 'id' => $exists->id ) + ); + } else { + $result = $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => 'awareness', + 'client_id' => $client_id, + 'user_id' => $user_id, + 'data' => $data, + 'date_gmt' => $now, + ) + ); + } + + if ( false === $result ) { + 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:' . str_replace( '/', ':', $room ); + $cached = wp_cache_get( $cache_key, 'collaboration' ); + + if ( false !== $cached ) { + $normalized_state = json_decode( $data, 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 true; + } +} 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 54% 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 a90821ab78d3e..b6c36dadae542 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; } @@ -150,14 +152,19 @@ public function register_routes(): void { 'type' => array( 'object', 'null' ), ), 'client_id' => array( - 'minimum' => 1, - 'required' => true, - 'type' => 'integer', + 'minimum' => 1, + 'minLength' => 1, + '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, @@ -167,32 +174,53 @@ 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' ), + 'validate_callback' => array( $this, 'validate_request' ), + 'args' => array( + 'rooms' => array( + 'items' => array( + 'properties' => $room_args, + 'type' => 'object', + ), + 'maxItems' => self::MAX_ROOMS_PER_REQUEST, + 'required' => true, + 'type' => 'array', + ), + ), + ); + 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' ), - 'validate_callback' => array( $this, 'validate_request' ), - 'args' => array( - 'rooms' => array( - 'items' => array( - 'properties' => $room_args, - 'type' => 'object', - ), - 'maxItems' => self::MAX_ROOMS_PER_REQUEST, - 'required' => true, - 'type' => 'array', - ), - ), - ) + $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. @@ -203,29 +231,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 ); @@ -234,13 +248,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() ) ); @@ -251,31 +265,29 @@ public function check_permissions( WP_REST_Request $request ) { } /** - * Validates that the request body does not exceed the maximum allowed size. + * Validates the incoming REST request. * - * Runs as the route-level validate_callback, after per-arg schema - * validation has already passed. + * Checks that the raw request body does not exceed the maximum allowed size. * * @since 7.0.0 * * @param WP_REST_Request $request The REST request. - * @return true|WP_Error True if valid, WP_Error if the body is too large. + * @return true|WP_Error True if valid, WP_Error if body is too large. */ public function validate_request( WP_REST_Request $request ) { $body = $request->get_body(); if ( is_string( $body ) && strlen( $body ) > self::MAX_BODY_SIZE ) { return new WP_Error( - 'rest_sync_body_too_large', + 'rest_collaboration_body_too_large', __( 'Request body is too large.' ), array( 'status' => 413 ) ); } - return true; } /** - * 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 @@ -295,18 +307,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 ) ) === (string) $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; } @@ -323,57 +339,46 @@ 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 * * @param string $entity_kind The entity kind, e.g. 'postType', 'taxonomy', 'root'. * @param string $entity_name The entity name, e.g. 'post', 'category', 'site'. - * @param string|null $object_id The numeric object ID / entity key for single entities, null for collections. + * @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 { - if ( is_string( $object_id ) ) { - if ( ! ctype_digit( $object_id ) ) { - return false; - } - $object_id = (int) $object_id; - } - if ( null !== $object_id && $object_id <= 0 ) { - // Object ID must be numeric if provided. + private function can_user_collaborate_on_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool { + // Reject non-numeric object IDs early. + if ( ! is_null( $object_id ) && ! is_numeric( $object_id ) ) { return false; } - // Validate permissions for the provided object ID. - if ( is_int( $object_id ) ) { - // Handle single post type entities with a defined object ID. - if ( 'postType' === $entity_kind ) { - if ( get_post_type( $object_id ) !== $entity_name ) { - // Post is not of the specified post type. - return false; - } - return current_user_can( 'edit_post', $object_id ); + // Handle single post type entities with a defined object ID. + if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { + if ( get_post_type( $object_id ) !== $entity_name ) { + return false; } + return current_user_can( 'edit_post', (int) $object_id ); + } - // Handle single taxonomy term entities with a defined object ID. - if ( 'taxonomy' === $entity_kind ) { - $term_exists = term_exists( $object_id, $entity_name ); - if ( ! is_array( $term_exists ) || ! isset( $term_exists['term_id'] ) ) { - // Either term doesn't exist OR term is not in specified taxonomy. - return false; - } - - return current_user_can( 'edit_term', $object_id ); + // Handle single taxonomy term entities with a defined object ID. + if ( 'taxonomy' === $entity_kind && is_numeric( $object_id ) ) { + if ( ! term_exists( (int) $object_id, $entity_name ) ) { + return false; } + return current_user_can( 'assign_term', (int) $object_id ); + } - // Handle single comment entities with a defined object ID. - if ( 'root' === $entity_kind && 'comment' === $entity_name ) { - return current_user_can( 'edit_comment', $object_id ); - } + // Handle single comment entities with a defined object ID. + if ( 'root' === $entity_kind && 'comment' === $entity_name && is_numeric( $object_id ) ) { + return current_user_can( 'edit_comment', (int) $object_id ); } - // All the remaining checks are for collections. If an object ID is provided, - // reject the request. + /* + * All the remaining checks are for collections. If an object ID is + * provided, reject the request. + */ if ( null !== $object_id ) { return false; } @@ -388,9 +393,11 @@ 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 - // another user has updated an entity in the collection. Therefore, we only - // compare against an allow list of collection types. + /* + * Collection collaboration does not exchange entity data. It only + * signals if another user has updated an entity in the collection. + * Therefore, we only compare against an allow list of collection types. + */ $allowed_collection_entity_kinds = array( 'postType', 'root', @@ -403,66 +410,68 @@ 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']; @@ -471,7 +480,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. @@ -487,19 +496,39 @@ private function process_sync_update( string $room, int $client_id, int $cursor, } if ( ! $has_newer_compaction ) { - if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { + /* + * 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_through_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 - // silently ignore this one. + /* + * Reaching this point means there's a newer compaction, + * so we can silently ignore this one. + */ return true; case self::UPDATE_TYPE_SYNC_STEP1: @@ -519,7 +548,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 ) ); } @@ -530,12 +559,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, @@ -543,10 +572,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 ); } @@ -554,7 +588,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. @@ -562,7 +596,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{ @@ -573,7 +607,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 ); 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/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 b225d35c48b2a..938a6efb6ae3b 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -657,42 +657,6 @@ function create_initial_post_types() { ) ); - 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( 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-includes/version.php b/src/wp-includes/version.php index 934e5d0bb5369..61237429dd9d5 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 = 61841; /** * Holds the TinyMCE version. 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'; 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..befcd14da5730 --- /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_collaboration_enabled: 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..5c34a5d88d901 --- /dev/null +++ b/tests/e2e/specs/collaboration/fixtures/index.js @@ -0,0 +1,69 @@ +/** + * 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, + testInfo + ) => { + const utils = new CollaborationUtils( { + admin, + editor, + requestUtils, + page, + } ); + + /* + * Skip collaboration tests when the JS runtime is not available. + * + * The collaboration client-side code lives in Gutenberg and may not + * be bundled in every CI environment. Enable the setting, navigate + * to the editor, and check whether the runtime loaded. + */ + await utils.setCollaboration( true ); + await admin.visitAdminPage( 'post-new.php' ); + await page.waitForFunction( () => window?.wp?.data && window?.wp?.blocks, { + timeout: 15000, + } ); + const hasRuntime = await page.evaluate( + () => !! window._wpCollaborationEnabled + ); + if ( ! hasRuntime ) { + testInfo.skip( true, 'Collaboration JS runtime is not available.' ); + return; + } + + await requestUtils.createUser( SECOND_USER ).catch( ( error ) => { + if ( error?.code !== 'existing_user_login' ) { + throw error; + } + } ); + 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/collaboration/wpSyncPostMetaStorage.php b/tests/phpunit/tests/collaboration/wpSyncPostMetaStorage.php deleted file mode 100644 index 8286fa643b45e..0000000000000 --- a/tests/phpunit/tests/collaboration/wpSyncPostMetaStorage.php +++ /dev/null @@ -1,707 +0,0 @@ -user->create( array( 'role' => 'editor' ) ); - self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); - update_option( 'wp_collaboration_enabled', 1 ); - } - - public static function wpTearDownAfterClass() { - self::delete_user( self::$editor_id ); - delete_option( 'wp_collaboration_enabled' ); - wp_delete_post( self::$post_id, true ); - } - - public function set_up() { - parent::set_up(); - update_option( 'wp_collaboration_enabled', 1 ); - - // Reset storage post ID cache to ensure clean state after transaction rollback. - $reflection = new ReflectionProperty( 'WP_Sync_Post_Meta_Storage', 'storage_post_ids' ); - if ( PHP_VERSION_ID < 80100 ) { - $reflection->setAccessible( true ); - } - $reflection->setValue( null, array() ); - } - - /** - * Returns the room identifier for the test post. - * - * @return string Room identifier. - */ - private function get_room(): string { - return 'postType/post:' . self::$post_id; - } - - /** - * Creates the storage post for the room and returns its ID. - * - * Adds a seed update to trigger storage post creation, then looks up - * the resulting post ID. - * - * @param WP_Sync_Post_Meta_Storage $storage Storage instance. - * @param string $room Room identifier. - * @return int Storage post ID. - */ - private function create_storage_post( WP_Sync_Post_Meta_Storage $storage, string $room ): int { - $storage->add_update( - $room, - array( - 'type' => 'update', - 'data' => 'seed', - ) - ); - - $posts = get_posts( - array( - 'post_type' => 'wp_sync_storage', - 'posts_per_page' => 1, - 'post_status' => 'publish', - 'name' => md5( $room ), - 'fields' => 'ids', - ) - ); - - $storage_post_id = array_first( $posts ); - $this->assertIsInt( $storage_post_id ); - - return $storage_post_id; - } - - /** - * Primes the post meta object cache for a given post and returns the cached value. - * - * @param int $post_id Post ID. - * @return array Cached meta data. - */ - private function prime_and_get_meta_cache( int $post_id ): array { - update_meta_cache( 'post', array( $post_id ) ); - - $cached = wp_cache_get( $post_id, 'post_meta' ); - $this->assertNotFalse( $cached, 'Post meta cache should be primed.' ); - - return $cached; - } - - /** - * Adding a sync update must not invalidate the post meta cache for the storage - * post. - * - * @ticket 64916 - */ - public function test_add_update_does_not_invalidate_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - $cached_before = $this->prime_and_get_meta_cache( $storage_post_id ); - - $storage->add_update( - $room, - array( - 'type' => 'update', - 'data' => 'new', - ) - ); - - $cached_after = wp_cache_get( $storage_post_id, 'post_meta' ); - $this->assertSame( - $cached_before, - $cached_after, - 'add_update() must not invalidate the post meta cache.' - ); - } - - /** - * Setting awareness state must not invalidate the post meta cache for the - * storage post. - * - * @ticket 64916 - */ - public function test_set_awareness_state_insert_does_not_invalidate_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - $cached_before = $this->prime_and_get_meta_cache( $storage_post_id ); - - // First call triggers an INSERT (no existing awareness row). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Test' ) ) ); - - $cached_after = wp_cache_get( $storage_post_id, 'post_meta' ); - $this->assertSame( - $cached_before, - $cached_after, - 'set_awareness_state() INSERT path must not invalidate the post meta cache.' - ); - } - - /** - * Updating awareness state must not invalidate the post meta cache for the - * storage post. - * - * @ticket 64916 - */ - public function test_set_awareness_state_update_does_not_invalidate_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Create initial awareness row (INSERT path). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Initial' ) ) ); - - // Prime cache after the insert. - $cached_before = $this->prime_and_get_meta_cache( $storage_post_id ); - - // Second call triggers an UPDATE (existing awareness row). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Updated' ) ) ); - - $cached_after = wp_cache_get( $storage_post_id, 'post_meta' ); - $this->assertSame( - $cached_before, - $cached_after, - 'set_awareness_state() UPDATE path must not invalidate the post meta cache.' - ); - } - - /** - * Removing updates / compaction must not invalidate the post meta cache for - * the storage post. - * - * @ticket 64916 - */ - public function test_remove_updates_before_cursor_does_not_invalidate_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Get a cursor after the seed update. - $storage->get_updates_after_cursor( $room, 0 ); - $cursor = $storage->get_cursor( $room ); - - $cached_before = $this->prime_and_get_meta_cache( $storage_post_id ); - - $storage->remove_updates_before_cursor( $room, $cursor ); - - $cached_after = wp_cache_get( $storage_post_id, 'post_meta' ); - $this->assertSame( - $cached_before, - $cached_after, - 'remove_updates_before_cursor() must not invalidate the post meta cache.' - ); - } - - /** - * Adding a sync update must not update the posts last_changed value. - * - * @ticket 64696 - */ - public function test_add_update_does_not_update_posts_last_changed() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $this->create_storage_post( $storage, $room ); - - $last_changed_before = wp_cache_get_last_changed( 'posts' ); - - $storage->add_update( - $room, - array( - 'type' => 'update', - 'data' => 'new', - ) - ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'add_update() must not update posts last_changed.' - ); - } - - /** - * Setting awareness state must not update the posts last_changed value. - * - * @ticket 64696 - */ - public function test_set_awareness_state_does_not_update_posts_last_changed() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $this->create_storage_post( $storage, $room ); - - $last_changed_before = wp_cache_get_last_changed( 'posts' ); - - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Test' ) ) ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'set_awareness_state() must not update posts last_changed.' - ); - } - - /** - * Updating awareness state must not update the posts last_changed value. - * - * @ticket 64916 - */ - public function test_set_awareness_state_update_does_not_update_posts_last_changed() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $this->create_storage_post( $storage, $room ); - - $last_changed_before = wp_cache_get_last_changed( 'posts' ); - - // Create initial awareness row (INSERT path). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Initial' ) ) ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'set_awareness_state() must not update posts last_changed.' - ); - - // Second call triggers an UPDATE (existing awareness row). - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Updated' ) ) ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'set_awareness_state() must not update posts last_changed.' - ); - } - - /** - * Removing sync updates / compaction must not update the posts last_changed - * value. - * - * @ticket 64916 - */ - public function test_remove_updates_before_cursor_does_not_update_posts_last_changed() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $this->create_storage_post( $storage, $room ); - - $storage->get_updates_after_cursor( $room, 0 ); - $cursor = $storage->get_cursor( $room ); - - $last_changed_before = wp_cache_get_last_changed( 'posts' ); - - $storage->remove_updates_before_cursor( $room, $cursor ); - - $this->assertSame( - $last_changed_before, - wp_cache_get_last_changed( 'posts' ), - 'remove_updates_before_cursor() must not update posts last_changed.' - ); - } - - /** - * Getting awareness state must not prime the post meta cache for the storage - * post. - * - * @ticket 64916 - */ - public function test_get_awareness_state_does_not_prime_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Populate awareness so there is data to read. - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Test' ) ) ); - - // Clear any existing cache. - wp_cache_delete( $storage_post_id, 'post_meta' ); - $this->assertFalse( - wp_cache_get( $storage_post_id, 'post_meta' ), - 'Post meta cache should be empty before read.' - ); - - $storage->get_awareness_state( $room ); - - $this->assertFalse( - wp_cache_get( $storage_post_id, 'post_meta' ), - 'get_awareness_state() must not prime the post meta cache.' - ); - } - - /** - * Getting sync updates must not prime the post meta cache for the storage - * post. - * - * @ticket 64916 - */ - public function test_get_updates_after_cursor_does_not_prime_post_meta_cache() { - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Clear any existing cache. - wp_cache_delete( $storage_post_id, 'post_meta' ); - $this->assertFalse( - wp_cache_get( $storage_post_id, 'post_meta' ), - 'Post meta cache should be empty before read.' - ); - - $storage->get_updates_after_cursor( $room, 0 ); - - $this->assertFalse( - wp_cache_get( $storage_post_id, 'post_meta' ), - 'get_updates_after_cursor() must not prime the post meta cache.' - ); - } - - /* - * Data integrity tests. - */ - - public function test_get_updates_after_cursor_drops_malformed_json() { - global $wpdb; - - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Advance cursor past the seed update from create_storage_post(). - $storage->get_updates_after_cursor( $room, 0 ); - $cursor = $storage->get_cursor( $room ); - - // Insert a valid update. - $valid_update = array( - 'type' => 'update', - 'data' => 'dGVzdA==', - ); - $this->assertTrue( $storage->add_update( $room, $valid_update ) ); - - // Insert a malformed JSON row directly into the database. - $wpdb->insert( - $wpdb->postmeta, - array( - 'post_id' => $storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, - 'meta_value' => '{invalid json', - ), - array( '%d', '%s', '%s' ) - ); - - // Insert another valid update after the malformed one. - $valid_update_2 = array( - 'type' => 'sync_step1', - 'data' => 'c3RlcDE=', - ); - $this->assertTrue( $storage->add_update( $room, $valid_update_2 ) ); - - $updates = $storage->get_updates_after_cursor( $room, $cursor ); - - // The malformed row should be dropped; only the valid updates should appear. - $this->assertCount( 2, $updates ); - $this->assertSame( $valid_update, $updates[0] ); - $this->assertSame( $valid_update_2, $updates[1] ); - } - - public function test_duplicate_awareness_rows_coalesces_obn_latest_row() { - global $wpdb; - - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Simulate a race: insert two awareness rows directly. - $wpdb->insert( - $wpdb->postmeta, - array( - 'post_id' => $storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::AWARENESS_META_KEY, - 'meta_value' => wp_json_encode( array( 1 => array( 'name' => 'Stale' ) ) ), - ), - array( '%d', '%s', '%s' ) - ); - - $wpdb->insert( - $wpdb->postmeta, - array( - 'post_id' => $storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::AWARENESS_META_KEY, - 'meta_value' => wp_json_encode( array( 1 => array( 'name' => 'Latest' ) ) ), - ), - array( '%d', '%s', '%s' ) - ); - - // get_awareness_state and set_awareness_state should target the latest row. - $awareness = $storage->get_awareness_state( $room ); - $this->assertSame( array( 'name' => 'Latest' ), $awareness[0] ); - $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Current' ) ) ); - $awareness = $storage->get_awareness_state( $room ); - $this->assertSame( array( 'name' => 'Current' ), $awareness[0] ); - } - - /* - * Race-condition tests. - * - * These use a $wpdb proxy to inject concurrent writes between internal - * query steps, verifying that the cursor-bounded query window prevents - * data loss. - */ - - public function test_cursor_does_not_skip_update_inserted_during_fetch_window() { - global $wpdb; - - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - $seed_update = array( - 'client_id' => 1, - 'type' => 'update', - 'data' => 'c2VlZA==', - ); - - $this->assertTrue( $storage->add_update( $room, $seed_update ) ); - - $initial_updates = $storage->get_updates_after_cursor( $room, 0 ); - $baseline_cursor = $storage->get_cursor( $room ); - - // The seed from create_storage_post() plus the one we just added. - $this->assertGreaterThan( 0, $baseline_cursor ); - - $injected_update = array( - 'client_id' => 9999, - 'type' => 'update', - 'data' => base64_encode( 'injected-during-fetch' ), - ); - - $original_wpdb = $wpdb; - $proxy_wpdb = new class( $original_wpdb, $storage_post_id, $injected_update ) { - private $wpdb; - private $storage_post_id; - private $injected_update; - public $postmeta; - public $did_inject = false; - - public function __construct( $wpdb, int $storage_post_id, array $injected_update ) { - $this->wpdb = $wpdb; - $this->storage_post_id = $storage_post_id; - $this->injected_update = $injected_update; - $this->postmeta = $wpdb->postmeta; - } - - // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Proxy forwards fully prepared core queries. - public function prepare( ...$args ) { - return $this->wpdb->prepare( ...$args ); - } - - public function get_row( $query = null, $output = OBJECT, $y = 0 ) { - $result = $this->wpdb->get_row( $query, $output, $y ); - - $this->maybe_inject_after_sync_query( $query ); - - return $result; - } - - public function get_var( $query = null, $x = 0, $y = 0 ) { - $result = $this->wpdb->get_var( $query, $x, $y ); - - $this->maybe_inject_after_sync_query( $query ); - - return $result; - } - - public function get_results( $query = null, $output = OBJECT ) { - return $this->wpdb->get_results( $query, $output ); - } - // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared - - public function __call( $name, $arguments ) { - return $this->wpdb->$name( ...$arguments ); - } - - public function __get( $name ) { - return $this->wpdb->$name; - } - - public function __set( $name, $value ) { - $this->wpdb->$name = $value; - } - - private function inject_update(): void { - if ( $this->did_inject ) { - return; - } - - $this->did_inject = true; - - $this->wpdb->insert( - $this->wpdb->postmeta, - array( - 'post_id' => $this->storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, - 'meta_value' => wp_json_encode( $this->injected_update ), - ), - array( '%d', '%s', '%s' ) - ); - } - - private function maybe_inject_after_sync_query( $query ): void { - if ( $this->did_inject || ! is_string( $query ) ) { - return; - } - - $targets_postmeta = false !== strpos( $query, $this->postmeta ); - $targets_post_id = 1 === preg_match( '/\bpost_id\s*=\s*' . (int) $this->storage_post_id . '\b/', $query ); - $targets_meta_key = 1 === preg_match( - "/\bmeta_key\s*=\s*'" . preg_quote( WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, '/' ) . "'/", - $query - ); - - if ( $targets_postmeta && $targets_post_id && $targets_meta_key ) { - $this->inject_update(); - } - } - }; - - $wpdb = $proxy_wpdb; - try { - $race_updates = $storage->get_updates_after_cursor( $room, $baseline_cursor ); - $race_cursor = $storage->get_cursor( $room ); - } finally { - $wpdb = $original_wpdb; - } - - $this->assertTrue( $proxy_wpdb->did_inject, 'Expected race-window update injection to occur.' ); - $this->assertEmpty( $race_updates ); - $this->assertSame( $baseline_cursor, $race_cursor ); - - $follow_up_updates = $storage->get_updates_after_cursor( $room, $race_cursor ); - $follow_up_cursor = $storage->get_cursor( $room ); - - $this->assertCount( 1, $follow_up_updates ); - $this->assertSame( $injected_update, $follow_up_updates[0] ); - $this->assertGreaterThan( $race_cursor, $follow_up_cursor ); - } - - public function test_compaction_does_not_delete_update_inserted_during_delete() { - global $wpdb; - - $storage = new WP_Sync_Post_Meta_Storage(); - $room = $this->get_room(); - $storage_post_id = $this->create_storage_post( $storage, $room ); - - // Seed three updates so there's something to compact. - for ( $i = 1; $i <= 3; $i++ ) { - $this->assertTrue( - $storage->add_update( - $room, - array( - 'client_id' => $i, - 'type' => 'update', - 'data' => base64_encode( "seed-$i" ), - ) - ) - ); - } - - // Capture the cursor after all seeds are in place. - $storage->get_updates_after_cursor( $room, 0 ); - $compaction_cursor = $storage->get_cursor( $room ); - $this->assertGreaterThan( 0, $compaction_cursor ); - - $concurrent_update = array( - 'client_id' => 9999, - 'type' => 'update', - 'data' => base64_encode( 'arrived-during-compaction' ), - ); - - $original_wpdb = $wpdb; - $proxy_wpdb = new class( $original_wpdb, $storage_post_id, $concurrent_update ) { - private $wpdb; - private $storage_post_id; - private $concurrent_update; - public $did_inject = false; - - public function __construct( $wpdb, int $storage_post_id, array $concurrent_update ) { - $this->wpdb = $wpdb; - $this->storage_post_id = $storage_post_id; - $this->concurrent_update = $concurrent_update; - } - - // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Proxy forwards fully prepared core queries. - public function prepare( ...$args ) { - return $this->wpdb->prepare( ...$args ); - } - - public function query( $query ) { - $result = $this->wpdb->query( $query ); - - // After the DELETE executes, inject a concurrent update via - // raw SQL through the real $wpdb to avoid metadata cache - // interactions while the proxy is active. - if ( ! $this->did_inject - && is_string( $query ) - && 0 === strpos( $query, "DELETE FROM {$this->wpdb->postmeta}" ) - && false !== strpos( $query, "post_id = {$this->storage_post_id}" ) - ) { - $this->did_inject = true; - $this->wpdb->insert( - $this->wpdb->postmeta, - array( - 'post_id' => $this->storage_post_id, - 'meta_key' => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, - 'meta_value' => wp_json_encode( $this->concurrent_update ), - ), - array( '%d', '%s', '%s' ) - ); - } - - return $result; - } - // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared - - public function __call( $name, $arguments ) { - return $this->wpdb->$name( ...$arguments ); - } - - public function __get( $name ) { - return $this->wpdb->$name; - } - - public function __set( $name, $value ) { - $this->wpdb->$name = $value; - } - }; - - // Run compaction through the proxy so the concurrent update - // is injected immediately after the DELETE executes. - $wpdb = $proxy_wpdb; - try { - $result = $storage->remove_updates_before_cursor( $room, $compaction_cursor ); - } finally { - $wpdb = $original_wpdb; - } - - $this->assertTrue( $result ); - $this->assertTrue( $proxy_wpdb->did_inject, 'Expected concurrent update injection to occur.' ); - - // The concurrent update must survive the compaction delete. - $updates = $storage->get_updates_after_cursor( $room, 0 ); - - $update_data = wp_list_pluck( $updates, 'data' ); - $this->assertContains( - $concurrent_update['data'], - $update_data, - 'Concurrent update should survive compaction.' - ); - } -} diff --git a/tests/phpunit/tests/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-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php new file mode 100644 index 0000000000000..640c214ead4a8 --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -0,0 +1,3161 @@ +user->create( array( 'role' => 'editor' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); + + // Enable option in setUpBeforeClass to ensure REST routes are registered. + update_option( 'wp_collaboration_enabled', 1 ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$editor_id ); + self::delete_user( self::$subscriber_id ); + delete_option( 'wp_collaboration_enabled' ); + wp_delete_post( self::$post_id, true ); + } + + public function set_up() { + parent::set_up(); + + // Enable option for tests. + update_option( 'wp_collaboration_enabled', 1 ); + + // Uses DELETE (not TRUNCATE) to preserve transaction rollback support + // in the test suite. TRUNCATE implicitly commits the transaction. + global $wpdb; + $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|null $awareness Awareness state, or null to skip the awareness write. + * @param array $updates Array of updates. + * @return array Room request data. + */ + private function build_room( $room, $client_id = '1', $cursor = 0, $awareness = array(), $updates = array() ) { + if ( is_array( $awareness ) && 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. + */ + + /** + * @ticket 64696 + */ + public function test_register_routes(): void { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp-collaboration/v1/updates', $routes ); + } + + /** + * Verifies the collaboration route is not registered when the option is + * not stored in the database (default is off). + * + * @ticket 64814 + */ + public function test_register_routes_without_option(): void { + 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-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. + } + + /** + * @ticket 64696 + */ + public function test_create_item(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $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. + } + + /* + * HTTP method and request format tests. + */ + + /** + * GET requests should return 404 because the route is registered + * for POST only and does not exist for other methods. + * + * @ticket 64696 + */ + public function test_collaboration_get_returns_404(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp-collaboration/v1/updates' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 404, $response->get_status(), 'GET should return 404 on a POST-only route.' ); + } + + /** + * PUT requests should return 404 because the route is registered + * for POST only. + * + * @ticket 64696 + */ + public function test_collaboration_put_returns_404(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'PUT', '/wp-collaboration/v1/updates' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 404, $response->get_status(), 'PUT should return 404 on a POST-only route.' ); + } + + /** + * DELETE requests should return 404 because the route is registered + * for POST only. + * + * @ticket 64696 + */ + public function test_collaboration_delete_returns_404(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp-collaboration/v1/updates' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 404, $response->get_status(), 'DELETE should return 404 on a POST-only route.' ); + } + + /** + * A POST with an invalid JSON body should return 400. + * + * @ticket 64696 + */ + public function test_collaboration_malformed_json_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( '{"rooms": [invalid json}' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 400, $response->get_status(), 'Malformed JSON should return 400.' ); + } + + /** + * A POST with a missing rooms parameter should return a 400 error. + * + * @ticket 64696 + */ + public function test_collaboration_missing_rooms_parameter(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_body_params( array() ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 400, $response->get_status(), 'Missing rooms parameter should return 400.' ); + } + + /* + * Permission tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_requires_authentication(): void { + wp_set_current_user( 0 ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_post_requires_edit_capability(): void { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_post_allowed_with_edit_capability(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_post_type_collection_requires_edit_posts_capability(): void { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_post_type_collection_allowed_with_edit_posts_capability(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_root_collection_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'root/site' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_taxonomy_collection_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'taxonomy/category' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_unknown_collection_kind_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'unknown/entity' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_non_posttype_entity_with_object_id_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'root/site:123' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_nonexistent_post_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post:999999' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_permission_checked_per_room(): void { + wp_set_current_user( self::$editor_id ); + + // First room is allowed, second room is forbidden. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $this->get_post_room() ), + $this->build_room( 'unknown/entity' ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * Verifies that a contributor can collaborate on their own draft post + * but is rejected from another author's post. + * + * Contributors have `edit_posts` but can only edit their own unpublished posts. + * + * @ticket 64696 + */ + public function test_collaboration_contributor_own_draft_allowed(): void { + $contributor_id = self::factory()->user->create( array( 'role' => 'contributor' ) ); + wp_set_current_user( $contributor_id ); + + // Contributor's own draft. + $own_draft = self::factory()->post->create( + array( + 'post_author' => $contributor_id, + 'post_status' => 'draft', + ) + ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'postType/post:' . $own_draft ), + ) + ); + + $this->assertSame( 200, $response->get_status(), 'Contributor should be able to collaborate on their own draft.' ); + + // Another author's post (self::$post_id belongs to the editor). + $response = $this->dispatch_collaboration( + array( + $this->build_room( $this->get_post_room() ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403, 'Contributor should not be able to collaborate on another author\'s post.' ); + } + + /** + * Verifies that a user with edit_comment capability can collaborate on a comment entity. + * + * The can_user_collaborate_on_entity_type() method handles root/comment:{id}. + * + * @ticket 64696 + */ + public function test_collaboration_comment_entity_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'user_id' => self::$editor_id, + ) + ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'root/comment:' . $comment_id ), + ) + ); + + $this->assertSame( 200, $response->get_status(), 'Editor should be able to collaborate on a comment entity.' ); + } + + /* + * Validation tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_invalid_room_format_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'invalid-room-format' ), + ) + ); + + $this->assertSame( 400, $response->get_status() ); + } + + /** + * Verifies that a numeric client_id is coerced to a string via the sanitize callback. + * + * The schema defines client_id as a string. Sending a numeric value (e.g. 42) + * should be cast to '42' and the round-trip should work correctly. + * + * @ticket 64696 + */ + public function test_collaboration_client_id_integer_coercion(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => array( 'user' => 'test' ), + 'client_id' => 42, + 'room' => $room, + 'updates' => array(), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Numeric client_id should be accepted.' ); + + $data = $response->get_data(); + $this->assertArrayHasKey( '42', $data['rooms'][0]['awareness'], 'Numeric client_id should be coerced to string key in awareness.' ); + } + + /** + * Verifies that dispatching with an empty rooms array returns HTTP 200. + * + * The schema has no minItems constraint on the rooms array. + * + * @ticket 64696 + */ + public function test_collaboration_empty_rooms_returns_200(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array() ); + + $this->assertSame( 200, $response->get_status(), 'Empty rooms array should return 200.' ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'rooms', $data, 'Response should contain rooms key.' ); + $this->assertEmpty( $data['rooms'], 'Response rooms should be empty.' ); + } + + /* + * Response format tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_response_structure(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $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 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_response_room_matches_request(): void { + 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(): 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->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'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_empty_updates_returns_zero_total(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); + + $data = $response->get_data(); + $this->assertSame( 0, $data['rooms'][0]['total_updates'] ); + $this->assertEmpty( $data['rooms'][0]['updates'] ); + } + + /* + * Update tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_update_delivered_to_other_client(): void { + 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 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_own_updates_not_returned(): void { + 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 ); + } + + /** + * Verifies that a client's own compaction update is returned to the sender. + * + * Regular updates are filtered out for the sending client, but compaction + * updates must be echoed back so the client knows the compaction was applied. + * + * @ticket 64696 + */ + public function test_collaboration_own_compaction_returned_to_sender(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => base64_encode( 'seed' ), + ); + + // Client 1 sends an update to seed the room. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + + $cursor = $response->get_data()['rooms'][0]['end_cursor']; + + // Client 1 sends a compaction. + $compaction = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted-state' ), + ); + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', $cursor, array( 'user' => 'c1' ), array( $compaction ) ), + ) + ); + + $data = $response->get_data(); + $updates = $data['rooms'][0]['updates']; + $types = wp_list_pluck( $updates, 'type' ); + + $this->assertContains( 'compaction', $types, 'Sender should receive their own compaction update back.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_step1_update_stored_and_returned(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $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 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_step2_update_stored_and_returned(): void { + 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 ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_multiple_updates_in_single_request(): void { + 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'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_update_data_preserved(): void { + 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'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_total_updates_increments(): void { + 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'] ); + } + + /** + * Verifies that get_updates_after_cursor returns updates in insertion order (ORDER BY id ASC). + * + * @ticket 64696 + */ + public function test_collaboration_update_ordering_preserved(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Send three updates in sequence from different clients. + for ( $i = 1; $i <= 3; $i++ ) { + $this->dispatch_collaboration( + array( + $this->build_room( + $room, + (string) $i, + 0, + array( 'user' => "client$i" ), + array( + array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ), + ) + ), + ) + ); + } + + // A new client fetches all updates from the beginning. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '4', 0 ), + ) + ); + + $data = $response->get_data(); + $update_data = wp_list_pluck( $data['rooms'][0]['updates'], 'data' ); + + $this->assertSame( + array( + base64_encode( 'update-1' ), + base64_encode( 'update-2' ), + base64_encode( 'update-3' ), + ), + $update_data, + 'Updates should be returned in insertion order.' + ); + } + + /* + * Compaction tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_should_compact_is_false_below_threshold(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $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'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_should_compact_is_true_above_threshold_for_compactor(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $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(): void { + 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.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_should_compact_is_false_for_non_compactor(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $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'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_stale_compaction_succeeds_when_newer_compaction_exists(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $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. + */ + + /** + * Verifies that a new client sees its own awareness state on its very + * first poll. The state is written after the awareness entries are read + * from storage, so the response relies on the manual injection in + * process_awareness_update() to include the client's own state. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_returned(): void { + wp_set_current_user( self::$editor_id ); + + $awareness = array( 'name' => 'Editor' ); + $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'], 'New client should see its own awareness on first poll.' ); + $this->assertSame( $awareness, $data['rooms'][0]['awareness']['1'], 'Awareness state should match what was sent.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_awareness_shows_multiple_clients(): void { + $room = $this->get_post_room(); + + // Client 1 connects as the editor. + wp_set_current_user( self::$editor_id ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'name' => 'Client 1' ) ), + ) + ); + + // Client 2 connects as a different user. + $editor_id_2 = self::factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $editor_id_2 ); + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'name' => 'Client 2' ) ), + ) + ); + + $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'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_awareness_updates_existing_client(): void { + 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'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_awareness_client_id_cannot_be_used_by_another_user(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // 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 ); + } + + /** + * Verifies that a client can reactivate with the same client ID after + * its awareness entry has expired (e.g., laptop closed and reopened). + * + * @ticket 64696 + */ + public function test_collaboration_awareness_client_reactivates_after_expiry(): void { + wp_set_current_user( self::$editor_id ); + global $wpdb; + + $room = $this->get_post_room(); + + // Client 1 registers awareness. + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'before-sleep' ) ), + ) + ); + + // Simulate the client going idle beyond the awareness timeout + // by backdating its awareness row. + $wpdb->update( + $wpdb->collaboration, + array( 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ) ), + array( + 'room' => $room, + 'type' => 'awareness', + 'client_id' => '1', + ) + ); + + // Flush the object cache so get_awareness_state() hits the DB. + wp_cache_flush(); + + // Another client polls — the expired client should not appear. + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'cursor' => 'observer' ) ), + ) + ); + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertArrayNotHasKey( '1', $awareness, 'Expired client should not appear in awareness.' ); + + // Original user returns and reconnects with the same client_id. + wp_set_current_user( self::$editor_id ); + wp_cache_flush(); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'after-sleep' ) ), + ) + ); + $awareness = $response->get_data()['rooms'][0]['awareness']; + + $this->assertSame( 200, $response->get_status(), 'Reactivation should succeed.' ); + $this->assertArrayHasKey( '1', $awareness, 'Reactivated client should appear in awareness.' ); + $this->assertSame( array( 'cursor' => 'after-sleep' ), $awareness['1'], 'Reactivated client should have updated state.' ); + + // Verify no duplicate rows were created. + $row_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE type = 'awareness' AND room = %s AND client_id = %s", + $room, + '1' + ) + ); + $this->assertSame( 1, $row_count, 'Should have exactly one awareness row after reactivation.' ); + } + + /* + * Multiple rooms tests. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_multiple_rooms_in_single_request(): void { + 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'] ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_rooms_are_isolated(): void { + wp_set_current_user( self::$editor_id ); + + $post_id_2 = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $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.' ); + } + + /** + * Verifies that the lowest client ID is correctly identified as the compactor + * and that compaction actually removes old rows from the database. + * + * @ticket 64696 + */ + public function test_collaboration_compactor_is_lowest_client_id(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 10 and client 5 both join and send updates. + $this->dispatch_collaboration( + array( + $this->build_room( + $room, + '10', + 0, + array( 'user' => 'c10' ), + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'update-from-10' ), + ), + ) + ), + ) + ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( + $room, + '5', + 0, + array( 'user' => 'c5' ), + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'update-from-5' ), + ), + ) + ), + ) + ); + + $data = $response->get_data(); + + // Client 5 is the lowest ID, so it should be the compactor candidate. + // Verify both clients appear in awareness (keys are client IDs). + $this->assertArrayHasKey( '5', $data['rooms'][0]['awareness'], 'Client 5 should appear in awareness.' ); + $this->assertArrayHasKey( '10', $data['rooms'][0]['awareness'], 'Client 10 should appear in awareness.' ); + + // Now add enough updates to exceed the compaction threshold. + $updates = array(); + for ( $i = 0; $i < 51; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "bulk-$i" ), + ); + } + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '10', 0, array( 'user' => 'c10' ), $updates ), + ) + ); + + // Client 5 (lowest) polls — should be told to compact. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '5', 0, array( 'user' => 'c5' ) ), + ) + ); + + $data = $response->get_data(); + $cursor = $data['rooms'][0]['end_cursor']; + $this->assertTrue( $data['rooms'][0]['should_compact'], 'Lowest client ID should be nominated as compactor.' ); + + // Client 10 (higher) polls — should NOT be told to compact. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '10', 0, array( 'user' => 'c10' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertFalse( $data['rooms'][0]['should_compact'], 'Higher client ID should not be nominated as compactor.' ); + + // Count rows before compaction. + $count_before = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'", + $room + ) + ); + + // Client 5 sends a compaction update. + $compaction = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted-state' ), + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, '5', $cursor, array( 'user' => 'c5' ), array( $compaction ) ), + ) + ); + + // Count rows after compaction. + $count_after = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness'", + $room + ) + ); + + $this->assertLessThan( $count_before, $count_after, 'Compaction should delete old rows from the database.' ); + } + + /** + * Verifies that compaction works when client IDs are integers. + * + * JSON payloads may decode numeric client IDs as integers rather + * than strings. The compactor comparison must handle both types. + * + * @ticket 64696 + */ + public function test_collaboration_compaction_with_integer_client_ids(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Both clients join with integer client IDs. + $this->dispatch_collaboration( + array( + $this->build_room( + $room, + 10, + 0, + array( 'user' => 'c10' ), + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'update-from-10' ), + ), + ) + ), + ) + ); + + // Add enough updates to exceed the compaction threshold. + $updates = array(); + for ( $i = 0; $i < 51; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "bulk-$i" ), + ); + } + + $this->dispatch_collaboration( + array( + $this->build_room( $room, 10, 0, array( 'user' => 'c10' ), $updates ), + ) + ); + + // Client 5 (lowest, integer) polls — should be told to compact. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, 5, 0, array( 'user' => 'c5' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertTrue( $data['rooms'][0]['should_compact'], 'Integer client ID should be correctly identified as compactor.' ); + } + + /* + * Cron cleanup tests. + */ + + /** + * 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 data column 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', + 'user_id' => self::$editor_id, + 'data' => 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', '%d', '%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 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.' ); + } + + /** + * @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.' + ); + } + + /** + * When collaboration is disabled, the cron callback should still clean up + * stale rows and then unschedule itself so it does not continue to run. + * + * @ticket 64696 + */ + public function test_cron_cleanup_when_collaboration_disabled(): void { + global $wpdb; + + // Insert a stale sync row (older than 7 days). + $this->insert_collaboration_row( 10 * DAY_IN_SECONDS ); + + // Insert a stale awareness row (older than 60 seconds). + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $this->get_post_room(), + 'type' => 'awareness', + 'client_id' => '42', + 'user_id' => self::$editor_id, + 'data' => wp_json_encode( array( 'cursor' => 'stale' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 120 ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + $this->assertSame( 1, $this->get_collaboration_row_count(), 'Should have 1 sync row before cleanup.' ); + $this->assertSame( 1, $this->get_awareness_row_count(), 'Should have 1 awareness row before cleanup.' ); + + // Schedule the cron event so we can verify it gets cleared. + wp_schedule_event( time(), 'hourly', 'wp_delete_old_collaboration_data' ); + $this->assertIsInt( wp_next_scheduled( 'wp_delete_old_collaboration_data' ), 'Cron event should be scheduled before cleanup.' ); + + // Disable collaboration. + update_option( 'wp_collaboration_enabled', false ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 0, $this->get_collaboration_row_count(), 'Stale sync rows should be deleted when collaboration is disabled.' ); + $this->assertSame( 0, $this->get_awareness_row_count(), 'Stale awareness rows should be deleted when collaboration is disabled.' ); + $this->assertFalse( wp_next_scheduled( 'wp_delete_old_collaboration_data' ), 'Cron hook should be unscheduled when collaboration is disabled.' ); + } + + /** + * Verifies that a fresh awareness row (younger than 60 seconds) survives cron cleanup. + * + * Existing tests verify expired awareness rows are deleted. This ensures + * the cleanup does not delete awareness rows that are still within the + * 60-second freshness window. + * + * @ticket 64696 + */ + public function test_cron_cleanup_preserves_fresh_awareness_rows(): void { + global $wpdb; + + // Insert a fresh awareness row (30 seconds old — well within the 60s threshold). + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $this->get_post_room(), + 'type' => 'awareness', + 'client_id' => '1', + 'user_id' => self::$editor_id, + 'data' => wp_json_encode( array( 'cursor' => 'active' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 30 ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + $this->assertSame( 1, $this->get_awareness_row_count(), 'Should have 1 awareness row before cleanup.' ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 1, $this->get_awareness_row_count(), 'Fresh awareness row should survive cron cleanup.' ); + } + + /* + * Route registration guard tests. + */ + + /** + * @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 61841.' ); + + // 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_through_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, + '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' ) + ); + + // 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, + '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' ) + ); + + // 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 data JSON blob. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_user_id_round_trip(): void { + 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, data 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->data, 'data column should not contain user_id.' ); + } + + /** + * Verifies that the is_array() guard in get_awareness_state() skips + * rows where the data column contains valid JSON that is not an array. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_non_array_json_ignored(): void { + 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, + 'data' => '"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(): void { + 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(): void { + 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(): void { + 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). + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, null ), + ) + ); + $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.' ); + } + + /* + * 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(); + + // Cold-cache baseline: flush the cache and dispatch client 1. + wp_cache_flush(); + $queries_before_cold = $wpdb->num_queries; + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + $queries_cold = $wpdb->num_queries - $queries_before_cold; + + // Warm-cache dispatch: client 2 polls the same room. Client 1's + // dispatch primed and updated the cache, so the awareness read + // should be served from cache. + $queries_before_warm = $wpdb->num_queries; + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, array( 'cursor' => 'pos-b' ) ), + ) + ); + + $queries_warm = $wpdb->num_queries - $queries_before_warm; + + $this->assertLessThan( + $queries_cold, + $queries_warm, + 'Warm-cache dispatch should use fewer queries than cold-cache dispatch.' + ); + } + + /** + * 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.' + ); + } + + /** + * Verifies that sync update writes do not invalidate the awareness cache. + * + * With post meta storage, add_post_meta() unconditionally calls + * wp_cache_delete() on the object's entire meta cache (meta.php:145), + * which would blow away cached awareness state on the same storage post. + * The dedicated table avoids this because sync writes and awareness + * reads use separate cache keys. + * + * @ticket 64696 + */ + public function test_collaboration_sync_write_does_not_invalidate_awareness_cache(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + /* Prime the awareness cache by dispatching client 1. */ + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + /* Send a sync update from client 2 — this is the write that would + * invalidate the awareness cache under post meta storage. */ + $update = array( + 'type' => 'update', + 'data' => base64_encode( 'sync-payload' ), + ); + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0, null, array( $update ) ), + ) + ); + + /* Now client 3 polls for awareness only. If the cache survived the + * sync write, this should require fewer queries than a cold start. */ + wp_cache_delete( 'last_changed', 'posts' ); + + $queries_before = $wpdb->num_queries; + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '3', 0, array( 'cursor' => 'pos-c' ) ), + ) + ); + + $queries_after = $wpdb->num_queries; + + /* Verify awareness data is intact. */ + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertArrayHasKey( '1', $awareness, 'Client 1 awareness should survive a sync write from client 2.' ); + + /* Flush cache and measure a cold-start dispatch for comparison. */ + wp_cache_flush(); + + $queries_before_cold = $wpdb->num_queries; + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '4', 0, array( 'cursor' => 'pos-d' ) ), + ) + ); + + $queries_cold = $wpdb->num_queries - $queries_before_cold; + $queries_warm = $queries_after - $queries_before; + + $this->assertLessThan( + $queries_cold, + $queries_warm, + 'Awareness read after a sync write should hit cache, not the database.' + ); + } + + /* + * Deprecated route tests. + */ + + /** + * 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(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'c3luYyByb3V0ZQ==', + ); + + // Send an update via the deprecated namespace. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'client1' ), array( $update ) ), + ), + 'wp-sync/v1' + ); + + $this->assertSame( 200, $response->get_status(), 'Deprecated wp-sync/v1 route should return 200.' ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'rooms', $data, 'Response should contain rooms key.' ); + $this->assertSame( $room, $data['rooms'][0]['room'], 'Room identifier should match.' ); + + // Verify the update is retrievable via the canonical namespace. + $response2 = $this->dispatch_collaboration( + array( + $this->build_room( $room, '2', 0 ), + ) + ); + + $updates = $response2->get_data()['rooms'][0]['updates']; + $this->assertNotEmpty( $updates, 'Update sent via deprecated route should be retrievable via canonical route.' ); + + $update_data = wp_list_pluck( $updates, 'data' ); + $this->assertContains( 'c3luYyByb3V0ZQ==', $update_data ); + } + + /* + * Payload limit and permission hardening tests. + */ + + /** + * Verifies that a request body exceeding MAX_BODY_SIZE returns a 413 error. + * + * @ticket 64696 + */ + public function test_collaboration_oversized_body_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + // Set a body larger than MAX_BODY_SIZE (16 MB). + $request->set_body( str_repeat( 'x', 16 * MB_IN_BYTES + 1 ) ); + $request->set_body_params( + array( + 'rooms' => array( + $this->build_room( $this->get_post_room() ), + ), + ) + ); + + $server = new WP_HTTP_Polling_Collaboration_Server( + new WP_Collaboration_Table_Storage() + ); + + $result = $server->validate_request( $request ); + + $this->assertWPError( $result ); + $this->assertSame( 'rest_collaboration_body_too_large', $result->get_error_code() ); + $this->assertSame( 413, $result->get_error_data()['status'] ); + } + + /** + * Verifies that more than MAX_ROOMS_PER_REQUEST rooms is rejected by schema validation. + * + * @ticket 64696 + */ + public function test_collaboration_too_many_rooms_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $rooms = array(); + for ( $i = 0; $i <= WP_HTTP_Polling_Collaboration_Server::MAX_ROOMS_PER_REQUEST; $i++ ) { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $rooms[] = $this->build_room( 'postType/post:' . $post_id, (string) $i ); + } + + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 400, $response->get_status(), 'Exceeding MAX_ROOMS_PER_REQUEST should return 400.' ); + } + + /** + * Verifies that a non-numeric object ID in a room name is rejected. + * + * @ticket 64696 + */ + public function test_collaboration_non_numeric_object_id_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'postType/post:abc' ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * Verifies that a post type mismatch (room says page but post is a post) is rejected. + * + * @ticket 64696 + */ + public function test_collaboration_post_type_mismatch_rejected(): void { + wp_set_current_user( self::$editor_id ); + + // self::$post_id is a 'post', but the room claims 'page'. + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'postType/page:' . self::$post_id ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * Verifies that a taxonomy term that doesn't exist is rejected. + * + * @ticket 64696 + */ + public function test_collaboration_nonexistent_taxonomy_term_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'taxonomy/category:999999' ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * Verifies that a taxonomy term in the wrong taxonomy is rejected. + * + * @ticket 64696 + */ + public function test_collaboration_taxonomy_term_wrong_taxonomy_rejected(): void { + wp_set_current_user( self::$editor_id ); + + // Create a term in 'category' taxonomy. + $term = self::factory()->term->create( array( 'taxonomy' => 'category' ) ); + + // Try to access it as a 'post_tag' term. + $response = $this->dispatch_collaboration( + array( + $this->build_room( 'taxonomy/post_tag:' . $term ), + ) + ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /* + * Feature gate tests. + * + * Verifies that wp_is_collaboration_enabled() properly gates + * functionality when the db_version requirement is not met, + * even if the option is enabled. This covers the multisite + * scenario where a sub-site admin enables RTC from the Writing + * settings page but the network upgrade has not been performed. + */ + + /** + * wp_is_collaboration_enabled() should return true when both the + * option and db_version conditions are met. + * + * @ticket 64696 + */ + public function test_wp_is_collaboration_enabled_true_when_both_conditions_met(): void { + update_option( 'wp_collaboration_enabled', 1 ); + + $this->assertTrue( wp_is_collaboration_enabled() ); + } + + /** + * wp_is_collaboration_enabled() should return false when the + * option is enabled but db_version is below the threshold. + * + * @ticket 64696 + */ + public function test_wp_is_collaboration_enabled_false_when_db_version_too_low(): void { + update_option( 'wp_collaboration_enabled', 1 ); + update_option( 'db_version', 61839 ); + + $this->assertFalse( wp_is_collaboration_enabled() ); + } + + /** + * wp_is_collaboration_enabled() should return false when the + * option is off, even if db_version is sufficient. + * + * @ticket 64696 + */ + public function test_wp_is_collaboration_enabled_false_when_option_off(): void { + update_option( 'wp_collaboration_enabled', 0 ); + + $this->assertFalse( wp_is_collaboration_enabled() ); + } + + /* + * Awareness deduplication tests. + * + * Verifies the UPDATE-then-INSERT pattern does not produce + * duplicate awareness rows for the same client in the same room. + */ + + /** + * Rapid sequential awareness writes for the same client should + * produce exactly one row, not duplicates. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_no_duplicate_rows(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Simulate rapid sequential awareness writes from the same client. + for ( $i = 0; $i < 5; $i++ ) { + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'cursor' => "pos-$i" ) ), + ) + ); + } + + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness' AND client_id = %s", + $room, + '1' + ) + ); + + $this->assertSame( 1, $count, 'Rapid awareness writes should produce exactly one row per client per room.' ); + } + + /** + * Multiple clients in the same room should each have exactly one + * awareness row after multiple write cycles. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_one_row_per_client(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Three clients each write awareness three times. + for ( $cycle = 0; $cycle < 3; $cycle++ ) { + for ( $client = 1; $client <= 3; $client++ ) { + $this->dispatch_collaboration( + array( + $this->build_room( $room, (string) $client, 0, array( 'cursor' => "cycle-$cycle" ) ), + ) + ); + } + } + + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND type = 'awareness'", + $room + ) + ); + + $this->assertSame( 3, $count, 'Each client should have exactly one awareness row regardless of write frequency.' ); + } + + /** + * Awareness state should reflect the most recent write, not an older value. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_reflects_latest_state(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Write awareness three times with different state. + $this->dispatch_collaboration( + array( $this->build_room( $room, '1', 0, array( 'cursor' => 'first' ) ) ) + ); + $this->dispatch_collaboration( + array( $this->build_room( $room, '1', 0, array( 'cursor' => 'second' ) ) ) + ); + $response = $this->dispatch_collaboration( + array( $this->build_room( $room, '1', 0, array( 'cursor' => 'third' ) ) ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertSame( array( 'cursor' => 'third' ), $awareness['1'], 'Awareness should reflect the most recent write.' ); + } + + /** + * An idle poll (no new updates, awareness already primed) should use + * fewer queries than the initial poll that seeds the room. + * + * @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(); + + // Initial poll — seeds awareness and primes cache. + $queries_before_initial = $wpdb->num_queries; + + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'test' ) ), + ) + ); + + $queries_initial = $wpdb->num_queries - $queries_before_initial; + + // Idle poll — awareness row already exists, cache is warm. + $queries_before_idle = $wpdb->num_queries; + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, array( 'user' => 'test' ) ), + ) + ); + + $queries_idle = $wpdb->num_queries - $queries_before_idle; + + $this->assertSame( 200, $response->get_status(), 'Idle poll should succeed.' ); + $this->assertLessThanOrEqual( + $queries_initial, + $queries_idle, + 'Idle poll should not use more queries than the initial poll.' + ); + } + + /* + * Cursor ID uniqueness tests. + * + * Auto-increment IDs guarantee unique ordering even when + * multiple updates arrive within the same millisecond. + * This was a known bug with the timestamp-based cursors + * used in the post meta implementation. + */ + + /** + * Updates stored in rapid succession must receive distinct, + * monotonically increasing cursor IDs. + * + * @ticket 64696 + */ + public function test_collaboration_cursor_ids_are_unique_and_ordered(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Send 10 updates as fast as possible from the same client. + $updates = array(); + for ( $i = 0; $i < 10; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "rapid-$i" ), + ); + } + $this->dispatch_collaboration( + array( + $this->build_room( $room, '1', 0, null, $updates ), + ) + ); + + $ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT id FROM {$wpdb->collaboration} WHERE room = %s AND type != 'awareness' ORDER BY id ASC", + $room + ) + ); + + $this->assertCount( 10, $ids, 'All 10 updates should be stored.' ); + + // Verify all IDs are unique. + $this->assertSame( count( $ids ), count( array_unique( $ids ) ), 'Every update should have a unique cursor ID.' ); + + // Verify IDs are strictly increasing. + $id_count = count( $ids ); + for ( $i = 1; $i < $id_count; $i++ ) { + $this->assertGreaterThan( + (int) $ids[ $i - 1 ], + (int) $ids[ $i ], + 'Cursor IDs must be strictly increasing.' + ); + } + } + + /* + * Room name tests. + * + * Room identifiers are stored unhashed so they remain + * human-readable and LIKE-queryable. + */ + + /** + * Room names stored in the table should be queryable with LIKE. + * + * Matt explicitly noted that unhashed, LIKE-able room names are + * a desirable property of the table design (comment 34). + * + * @ticket 64696 + */ + public function test_collaboration_room_names_are_likeable(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $post_id_2 = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + + // Write updates to two different post rooms. + $this->dispatch_collaboration( + array( + $this->build_room( + 'postType/post:' . self::$post_id, + '1', + 0, + null, + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'a' ), + ), + ) + ), + ) + ); + $this->dispatch_collaboration( + array( + $this->build_room( + 'postType/post:' . $post_id_2, + '1', + 0, + null, + array( + array( + 'type' => 'update', + 'data' => base64_encode( 'b' ), + ), + ) + ), + ) + ); + + // LIKE query for all post rooms. + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room LIKE %s AND type != 'awareness'", + 'postType/post:%' + ) + ); + + $this->assertSame( 2, $count, 'LIKE query should find updates across all post rooms.' ); + } + + /* + * Table extensibility tests. + * + * The table is designed as a general-purpose primitive + * that supports arbitrary type values for future use cases. + */ + + /** + * The table schema should accept arbitrary type values, + * supporting future use cases like CRDT document persistence. + * + * @ticket 64696 + */ + public function test_collaboration_table_accepts_arbitrary_types(): void { + global $wpdb; + + $room = $this->get_post_room(); + + // Insert a row with a custom type directly (simulating a future use case). + $result = $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'type' => 'persisted_crdt_doc', + 'client_id' => '0', + 'user_id' => self::$editor_id, + 'data' => wp_json_encode( array( 'doc' => 'base64data' ) ), + 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + ), + array( '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + $this->assertNotFalse( $result, 'Insert with custom type should succeed.' ); + + // Verify the row persists and is queryable. + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT type, data FROM {$wpdb->collaboration} WHERE room = %s AND type = 'persisted_crdt_doc'", + $room + ) + ); + + $this->assertNotNull( $row, 'Custom type row should be queryable.' ); + $this->assertSame( 'persisted_crdt_doc', $row->type, 'Type column should store the custom value.' ); + } + + /* + * Storage validation tests. + * + * Verify that storage methods reject empty required fields + * rather than inserting rows with default empty values. + */ + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_add_update_rejects_empty_room(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->add_update( + '', + array( + 'type' => 'update', + 'client_id' => '1', + 'data' => 'test', + ) + ); + $this->assertFalse( $result, 'add_update should reject an empty room.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_add_update_rejects_empty_type(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->add_update( + 'postType/post:1', + array( + 'type' => '', + 'client_id' => '1', + 'data' => 'test', + ) + ); + $this->assertFalse( $result, 'add_update should reject an empty type.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_add_update_rejects_empty_client_id(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->add_update( + 'postType/post:1', + array( + 'type' => 'update', + 'client_id' => '', + 'data' => 'test', + ) + ); + $this->assertFalse( $result, 'add_update should reject an empty client_id.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_set_awareness_rejects_empty_room(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->set_awareness_state( '', '1', array( 'user' => 'test' ), 1 ); + $this->assertFalse( $result, 'set_awareness_state should reject an empty room.' ); + } + + /** + * @ticket 64696 + */ + public function test_collaboration_storage_set_awareness_rejects_empty_client_id(): void { + $storage = new WP_Collaboration_Table_Storage(); + $result = $storage->set_awareness_state( 'postType/post:1', '', array( 'user' => 'test' ), 1 ); + $this->assertFalse( $result, 'set_awareness_state should reject an empty client_id.' ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 9c6c431e5ef35..b88758097b23c 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -16,6 +16,12 @@ 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_collaboration_enabled', '__return_true' ); + /** @var WP_REST_Server $wp_rest_server */ global $wp_rest_server; $wp_rest_server = new Spy_REST_Server(); @@ -203,6 +209,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 +224,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 7ded16bd3b033..0000000000000 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ /dev/null @@ -1,1155 +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 ) ); - self::$category_id = $factory->category->create(); - self::$tag_id = $factory->tag->create(); - self::$comment_id = $factory->comment->create( array( 'comment_post_ID' => self::$post_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 ); - wp_delete_term( self::$category_id, 'category' ); - wp_delete_term( self::$tag_id, 'post_tag' ); - wp_delete_comment( self::$comment_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 ); - } - - /** - * @ticket 64890 - */ - public function test_sync_malformed_object_id_rejected() { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:1abc' ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - /** - * @ticket 64890 - */ - public function test_sync_zero_object_id_rejected(): void { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:0' ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - /** - * @ticket 64890 - */ - public function test_sync_post_type_mismatch_rejected(): void { - wp_set_current_user( self::$editor_id ); - - // The test post is of type 'post', not 'page'. - $response = $this->dispatch_sync( array( $this->build_room( 'postType/page:' . self::$post_id ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - /** - * @ticket 64890 - */ - public function test_sync_taxonomy_term_allowed(): void { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:' . self::$category_id ) ) ); - - $this->assertSame( 200, $response->get_status() ); - } - - /** - * @ticket 64890 - */ - public function test_sync_nonexistent_taxonomy_term_rejected(): void { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:999999' ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - /** - * @ticket 64890 - */ - public function test_sync_taxonomy_term_wrong_taxonomy_rejected(): void { - wp_set_current_user( self::$editor_id ); - - // The tag term exists in 'post_tag', not 'category'. - $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:' . self::$tag_id ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - /** - * @ticket 64890 - */ - public function test_sync_comment_allowed(): void { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'root/comment:' . self::$comment_id ) ) ); - - $this->assertSame( 200, $response->get_status() ); - } - - /** - * @ticket 64890 - */ - public function test_sync_nonexistent_comment_rejected(): void { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'root/comment:999999' ) ) ); - - $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); - } - - /** - * @ticket 64890 - */ - public function test_sync_nonexistent_post_type_collection_rejected(): void { - wp_set_current_user( self::$editor_id ); - - $response = $this->dispatch_sync( array( $this->build_room( 'postType/nonexistent_type' ) ) ); - - $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() ); - } - - /** - * Verifies that schema type validation rejects a non-string value for the - * update 'data' field, confirming that per-arg schema validation still runs - * with a route-level validate_callback registered. - * - * @ticket 64890 - */ - public function test_sync_rejects_non_string_update_data(): void { - wp_set_current_user( self::$editor_id ); - - $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); - $request->set_body_params( - array( - 'rooms' => array( - array( - 'after' => 0, - 'awareness' => array( 'user' => 'test' ), - 'client_id' => 1, - 'room' => $this->get_post_room(), - 'updates' => array( - array( - 'data' => 12345, - 'type' => 'update', - ), - ), - ), - ), - ) - ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); - } - - /** - * Verifies that schema enum validation rejects an invalid update type, - * confirming that per-arg schema validation still runs with a route-level - * validate_callback registered. - * - * @ticket 64890 - */ - public function test_sync_rejects_invalid_update_type_enum(): void { - wp_set_current_user( self::$editor_id ); - - $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); - $request->set_body_params( - array( - 'rooms' => array( - array( - 'after' => 0, - 'awareness' => array( 'user' => 'test' ), - 'client_id' => 1, - 'room' => $this->get_post_room(), - 'updates' => array( - array( - 'data' => 'dGVzdA==', - 'type' => 'invalid_type', - ), - ), - ), - ), - ) - ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); - } - - /** - * Verifies that schema required-field validation rejects a room missing - * the 'client_id' field, confirming that per-arg schema validation still - * runs with a route-level validate_callback registered. - * - * @ticket 64890 - */ - public function test_sync_rejects_missing_required_room_field(): void { - wp_set_current_user( self::$editor_id ); - - $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); - $request->set_body_params( - array( - 'rooms' => array( - array( - 'after' => 0, - 'awareness' => array( 'user' => 'test' ), - // 'client_id' deliberately omitted. - 'room' => $this->get_post_room(), - 'updates' => array(), - ), - ), - ) - ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); - } - - /** - * Verifies that the maxItems constraint rejects a request with more rooms - * than MAX_ROOMS_PER_REQUEST. - * - * @ticket 64890 - */ - public function test_sync_rejects_rooms_exceeding_max_items(): void { - wp_set_current_user( self::$editor_id ); - - $rooms = array(); - for ( $i = 0; $i < WP_HTTP_Polling_Sync_Server::MAX_ROOMS_PER_REQUEST + 1; $i++ ) { - $rooms[] = $this->build_room( 'root/site', $i + 1 ); - } - - $response = $this->dispatch_sync( $rooms ); - $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); - } - - /** - * Verifies that the maxLength constraint rejects update data exceeding - * MAX_UPDATE_DATA_SIZE. - * - * @ticket 64890 - */ - public function test_sync_rejects_update_data_exceeding_max_length(): void { - wp_set_current_user( self::$editor_id ); - - $oversized_data = str_repeat( 'a', WP_HTTP_Polling_Sync_Server::MAX_UPDATE_DATA_SIZE + 1 ); - - $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); - $request->set_body_params( - array( - 'rooms' => array( - array( - 'after' => 0, - 'awareness' => array( 'user' => 'test' ), - 'client_id' => 1, - 'room' => $this->get_post_room(), - 'updates' => array( - array( - 'data' => $oversized_data, - 'type' => 'update', - ), - ), - ), - ), - ) - ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); - } - - /** - * Verifies that the route-level validate_callback rejects a request body - * exceeding MAX_BODY_SIZE. - * - * @ticket 64890 - */ - public function test_sync_rejects_oversized_request_body(): void { - wp_set_current_user( self::$editor_id ); - - $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); - - // Set valid parsed params so per-arg schema validation passes first. - $request->set_body_params( - array( - 'rooms' => array( - $this->build_room( $this->get_post_room() ), - ), - ) - ); - - // Set an oversized raw body to trigger the route-level validate_callback. - $request->set_body( str_repeat( 'x', WP_HTTP_Polling_Sync_Server::MAX_BODY_SIZE + 1 ) ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_sync_body_too_large', $response, 413 ); - } - - /* - * 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 c3ca057691308..5d1095a0b4565 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -20,7 +20,9 @@ mockedApiResponse.Schema = { "wp/v2", "wp-site-health/v1", "wp-block-editor/v1", - "wp-abilities/v1" + "wp-abilities/v1", + "wp-collaboration/v1", + "wp-sync/v1" ], "authentication": { "application-passwords": { @@ -12704,6 +12706,238 @@ mockedApiResponse.Schema = { } } ] + }, + "/wp-collaboration/v1": { + "namespace": "wp-collaboration/v1", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "namespace": { + "default": "wp-collaboration/v1", + "required": false + }, + "context": { + "default": "view", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-collaboration/v1" + } + ] + } + }, + "/wp-collaboration/v1/updates": { + "namespace": "wp-collaboration/v1", + "methods": [ + "POST" + ], + "endpoints": [ + { + "methods": [ + "POST" + ], + "args": { + "rooms": { + "items": { + "properties": { + "after": { + "minimum": 0, + "required": true, + "type": "integer" + }, + "awareness": { + "required": true, + "type": [ + "object", + "null" + ] + }, + "client_id": { + "minimum": 1, + "minLength": 1, + "required": true, + "type": [ + "string", + "integer" + ], + "sanitize_callback": {} + }, + "room": { + "required": true, + "type": "string", + "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", + "maxLength": 191 + }, + "updates": { + "items": { + "properties": { + "data": { + "type": "string", + "required": true, + "maxLength": 1048576 + }, + "type": { + "type": "string", + "required": true, + "enum": [ + "compaction", + "sync_step1", + "sync_step2", + "update" + ] + } + }, + "required": true, + "type": "object" + }, + "minItems": 0, + "required": true, + "type": "array" + } + }, + "type": "object" + }, + "maxItems": 50, + "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, + "minLength": 1, + "required": true, + "type": [ + "string", + "integer" + ], + "sanitize_callback": {} + }, + "room": { + "required": true, + "type": "string", + "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", + "maxLength": 191 + }, + "updates": { + "items": { + "properties": { + "data": { + "type": "string", + "required": true, + "maxLength": 1048576 + }, + "type": { + "type": "string", + "required": true, + "enum": [ + "compaction", + "sync_step1", + "sync_step2", + "update" + ] + } + }, + "required": true, + "type": "object" + }, + "minItems": 0, + "required": true, + "type": "array" + } + }, + "type": "object" + }, + "maxItems": 50, + "type": "array", + "required": true + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-sync/v1/updates" + } + ] + } } }, "site_logo": 0, @@ -14563,7 +14797,7 @@ mockedApiResponse.settings = { "use_smilies": true, "default_category": 1, "default_post_format": "0", - "wp_collaboration_enabled": false, + "wp_collaboration_enabled": true, "posts_per_page": 10, "show_on_front": "posts", "page_on_front": 0,