Make WordPress Core

Changeset 62334 for trunk


Ignore:
Timestamp:
05/08/2026 02:03:18 PM (4 months ago)
Author:
ellatrix
Message:

Remove real-time collaboration.

Removes all RTC related code from core. There will still be RTC related code in Gutenberg, but effectively disabled for core since nothing turns it on. The wp.sync global has also been hidden by bundling in https://github.com/WordPress/gutenberg/pull/78085.

Developed in: https://github.com/WordPress/wordpress-develop/pull/11774.

Props maxschmeling, ellatrix, mukesh27.
Fixes #65205.

Location:
trunk
Files:
23 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/js/_enqueues/admin/inline-edit-post.js

    r62074 r62334  
    615615}).on( 'heartbeat-tick.wp-check-locked-posts', function( e, data ) {
    616616        var locked = data['wp-check-locked-posts'] || {},
    617                 isRtc = window._wpCollaborationEnabled,
    618                 lockedClass = isRtc ? 'wp-collaborative-editing' : 'wp-locked';
     617                lockedClass = 'wp-locked';
    619618
    620619        $('#the-list tr').each( function(i, el) {
     
    627626                                row.find('.check-column checkbox').prop('checked', false);
    628627
    629                                 if ( ! isRtc && lock_data.avatar_src ) {
     628                                if ( lock_data.avatar_src ) {
    630629                                        avatar = $( '<img />', {
    631630                                                'class': 'avatar avatar-18 photo',
  • trunk/src/wp-admin/css/list-tables.css

    r62296 r62334  
    636636}
    637637
    638 .wp-collaborative-editing .locked-info {
    639         display: block;
    640 }
    641 
    642 .join-action-text {
    643         display: none;
    644 }
    645 
    646 .wp-collaborative-editing .edit-action-text {
    647         display: none;
    648 }
    649 
    650 .wp-collaborative-editing .join-action-text {
    651         display: inline;
    652 }
    653 
    654638#menu-locations-wrap .widefat {
    655639        width: 60%;
  • trunk/src/wp-admin/includes/class-wp-posts-list-table.php

    r62186 r62334  
    11201120
    11211121                        if ( $lock_holder ) {
    1122                                 if ( get_option( 'wp_collaboration_enabled' ) ) {
    1123                                         $locked_avatar = '';
    1124                                         /* translators: Collaboration status message for a singular post in the post list. Can be any type of post. */
    1125                                         $locked_text   = esc_html_x( 'Currently being edited', 'post list' );
    1126                                 } else {
    1127                                         $lock_holder   = get_userdata( $lock_holder );
    1128                                         $locked_avatar = get_avatar( $lock_holder->ID, 18 );
    1129                                         /* translators: %s: User's display name. */
    1130                                         $locked_text = esc_html( sprintf( __( '%s is currently editing' ), $lock_holder->display_name ) );
    1131                                 }
     1122                                $lock_holder   = get_userdata( $lock_holder );
     1123                                $locked_avatar = get_avatar( $lock_holder->ID, 18 );
     1124                                /* translators: %s: User's display name. */
     1125                                $locked_text = esc_html( sprintf( __( '%s is currently editing' ), $lock_holder->display_name ) );
    11321126                        } else {
    11331127                                $locked_avatar = '';
     
    14341428
    14351429                if ( $lock_holder ) {
    1436                         if ( get_option( 'wp_collaboration_enabled' ) ) {
    1437                                 $classes .= ' wp-collaborative-editing';
    1438                         } else {
    1439                                 $classes .= ' wp-locked';
    1440                         }
     1430                        $classes .= ' wp-locked';
    14411431                }
    14421432
     
    14921482
    14931483                if ( $can_edit_post && 'trash' !== $post->post_status ) {
    1494                         $is_rtc_enabled = (bool) get_option( 'wp_collaboration_enabled' );
    1495 
    1496                         /*
    1497                          * When RTC is enabled, both "Edit" and "Join" labels are rendered.
    1498                          * The visible label is toggled by CSS based on the row's
    1499                          * `wp-collaborative-editing` class, which is added or removed by
    1500                          * inline-edit-post.js in response to heartbeat ticks.
    1501                          */
    1502                         if ( $is_rtc_enabled ) {
    1503                                 $actions['edit'] = sprintf(
    1504                                         '<a href="%1$s">'
    1505                                         . '<span class="edit-action-text">'
    1506                                         . '<span aria-hidden="true">%2$s</span>'
    1507                                         . '<span class="screen-reader-text">%3$s</span>'
    1508                                         . '</span>'
    1509                                         . '<span class="join-action-text">'
    1510                                         . '<span aria-hidden="true">%4$s</span>'
    1511                                         . '<span class="screen-reader-text">%5$s</span>'
    1512                                         . '</span>'
    1513                                         . '</a>',
    1514                                         get_edit_post_link( $post->ID ),
    1515                                         __( 'Edit' ),
    1516                                         /* translators: %s: Post title. */
    1517                                         sprintf( __( 'Edit &#8220;%s&#8221;' ), $title ),
    1518                                         /* translators: Action link text for a singular post in the post list. Can be any type of post. */
    1519                                         _x( 'Join', 'post list' ),
    1520                                         /* translators: %s: Post title. */
    1521                                         sprintf( __( 'Join editing &#8220;%s&#8221;', 'post list' ), $title )
    1522                                 );
    1523                         } else {
    1524                                 $actions['edit'] = sprintf(
    1525                                         '<a href="%s" aria-label="%s">%s</a>',
    1526                                         get_edit_post_link( $post->ID ),
    1527                                         /* translators: %s: Post title. */
    1528                                         esc_attr( sprintf( __( 'Edit &#8220;%s&#8221;' ), $title ) ),
    1529                                         __( 'Edit' )
    1530                                 );
    1531                         }
     1484                        $actions['edit'] = sprintf(
     1485                                '<a href="%s" aria-label="%s">%s</a>',
     1486                                get_edit_post_link( $post->ID ),
     1487                                /* translators: %s: Post title. */
     1488                                esc_attr( sprintf( __( 'Edit &#8220;%s&#8221;' ), $title ) ),
     1489                                __( 'Edit' )
     1490                        );
    15321491
    15331492                        /**
  • trunk/src/wp-admin/includes/misc.php

    r62089 r62334  
    11371137 */
    11381138function wp_check_locked_posts( $response, $data, $screen_id ) {
    1139         $checked        = array();
    1140         $is_rtc_enabled = (bool) get_option( 'wp_collaboration_enabled' );
     1139        $checked = array();
    11411140
    11421141        if ( array_key_exists( 'wp-check-locked-posts', $data ) && is_array( $data['wp-check-locked-posts'] ) ) {
     
    11541153
    11551154                                if ( $user && current_user_can( 'edit_post', $post_id ) ) {
    1156                                         if ( $is_rtc_enabled ) {
    1157                                                 $send = array(
    1158                                                         /* translators: Collaboration status message for a singular post in the post list. Can be any type of post. */
    1159                                                         'text'          => _x( 'Currently being edited', 'post list' ),
    1160                                                         'collaborative' => true,
    1161                                                 );
    1162                                         } else {
    1163                                                 $send = array(
    1164                                                         'name' => $user->display_name,
    1165                                                         /* translators: %s: User's display name. */
    1166                                                         'text' => sprintf( __( '%s is currently editing' ), $user->display_name ),
    1167                                                 );
    1168 
    1169                                                 if ( get_option( 'show_avatars' ) ) {
    1170                                                         $send['avatar_src']    = get_avatar_url( $user->ID, array( 'size' => 18 ) );
    1171                                                         $send['avatar_src_2x'] = get_avatar_url( $user->ID, array( 'size' => 36 ) );
    1172                                                 }
     1155                                        $send = array(
     1156                                                'name' => $user->display_name,
     1157                                                /* translators: %s: User's display name. */
     1158                                                'text' => sprintf( __( '%s is currently editing' ), $user->display_name ),
     1159                                        );
     1160
     1161                                        if ( get_option( 'show_avatars' ) ) {
     1162                                                $send['avatar_src']    = get_avatar_url( $user->ID, array( 'size' => 18 ) );
     1163                                                $send['avatar_src_2x'] = get_avatar_url( $user->ID, array( 'size' => 36 ) );
    11731164                                        }
    11741165
  • trunk/src/wp-admin/includes/schema.php

    r62058 r62334  
    564564                // 6.9.0
    565565                'wp_notes_notify'                 => 1,
    566 
    567                 // 7.0.0
    568                 'wp_collaboration_enabled'        => 0,
    569566        );
    570567
  • trunk/src/wp-admin/options-writing.php

    r62100 r62334  
    108108<?php endforeach; ?>
    109109        </select>
    110 </td>
    111 </tr>
    112 <tr>
    113 <th scope="row"><?php _e( 'Collaboration' ); ?></th>
    114 <td>
    115         <?php if ( wp_is_collaboration_allowed() ) : ?>
    116                 <label for="wp_collaboration_enabled">
    117                         <input name="wp_collaboration_enabled" type="checkbox" id="wp_collaboration_enabled" value="1" <?php checked( '1', (bool) get_option( 'wp_collaboration_enabled' ) ); ?> />
    118                         <?php _e( "Enable early access to real-time collaboration. Real-time collaboration may affect your website's performance." ); ?>
    119                 </label>
    120         <?php else : ?>
    121                 <div class="notice notice-warning inline">
    122                         <p><?php _e( '<strong>Note:</strong> Real-time collaboration has been disabled.' ); ?></p>
    123                 </div>
    124         <?php endif; ?>
    125110</td>
    126111</tr>
  • trunk/src/wp-admin/options.php

    r62058 r62334  
    154154                'default_link_category',
    155155                'default_post_format',
    156                 'wp_collaboration_enabled',
    157156        ),
    158157);
  • trunk/src/wp-includes/collaboration.php

    r62100 r62334  
    1 <?php
    2 /**
    3  * Bootstraps collaborative editing.
    4  *
    5  * @package WordPress
    6  * @since 7.0.0
    7  */
    8 
    9 /**
    10  * Determines whether real-time collaboration is enabled.
    11  *
    12  * If the WP_ALLOW_COLLABORATION constant is false,
    13  * collaboration is always disabled regardless of the database option.
    14  * Otherwise, falls back to the 'wp_collaboration_enabled' option.
    15  *
    16  * @since 7.0.0
    17  *
    18  * @return bool Whether real-time collaboration is enabled.
    19  */
    20 function wp_is_collaboration_enabled() {
    21         return (
    22                 wp_is_collaboration_allowed() &&
    23                 (bool) get_option( 'wp_collaboration_enabled' )
    24         );
    25 }
    26 
    27 /**
    28  * Determines whether real-time collaboration is allowed.
    29  *
    30  * If the WP_ALLOW_COLLABORATION constant is false,
    31  * collaboration is not allowed and cannot be enabled.
    32  * The constant defaults to true, unless the WP_ALLOW_COLLABORATION
    33  * environment variable is set to string "false".
    34  *
    35  * @since 7.0.0
    36  *
    37  * @return bool Whether real-time collaboration is enabled.
    38  */
    39 function wp_is_collaboration_allowed() {
    40         if ( ! defined( 'WP_ALLOW_COLLABORATION' ) ) {
    41                 $env_value = getenv( 'WP_ALLOW_COLLABORATION' );
    42                 if ( false === $env_value ) {
    43                         // Environment variable is not defined, default to allowing collaboration.
    44                         define( 'WP_ALLOW_COLLABORATION', true );
    45                 } else {
    46                         /*
    47                          * Environment variable is defined, let's confirm it is actually set to
    48                          * "true" as it may still have a string value "false" – the preceeding
    49                          * `if` branch only tests for the boolean `false`.
    50                          */
    51                         define( 'WP_ALLOW_COLLABORATION', 'true' === $env_value );
    52                 }
    53         }
    54 
    55         return WP_ALLOW_COLLABORATION;
    56 }
    57 
    58 /**
    59  * Injects the real-time collaboration setting into a global variable.
    60  *
    61  * @since 7.0.0
    62  *
    63  * @access private
    64  *
    65  * @global string $pagenow The filename of the current screen.
    66  */
    67 function wp_collaboration_inject_setting() {
    68         global $pagenow;
    69 
    70         if ( ! wp_is_collaboration_enabled() ) {
    71                 return;
    72         }
    73 
    74         // Disable real-time collaboration on the site editor.
    75         $enabled = true;
    76         if ( 'site-editor.php' === $pagenow ) {
    77                 $enabled = false;
    78         }
    79 
    80         wp_add_inline_script(
    81                 'wp-core-data',
    82                 'window._wpCollaborationEnabled = ' . wp_json_encode( $enabled ) . ';',
    83                 'after'
    84         );
    85 }
  • trunk/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php

    r62298 r62334  
    1 <?php
    2 /**
    3  * WP_HTTP_Polling_Sync_Server class
    4  *
    5  * @package WordPress
    6  */
    7 
    8 /**
    9  * Core class that contains an HTTP server used for collaborative editing.
    10  *
    11  * @since 7.0.0
    12  * @access private
    13  */
    14 class WP_HTTP_Polling_Sync_Server {
    15         /**
    16          * REST API namespace.
    17          *
    18          * @since 7.0.0
    19          * @var string
    20          */
    21         const REST_NAMESPACE = 'wp-sync/v1';
    22 
    23         /**
    24          * Awareness timeout in seconds. Clients that haven't updated
    25          * their awareness state within this time are considered disconnected.
    26          *
    27          * @since 7.0.0
    28          * @var int
    29          */
    30         const AWARENESS_TIMEOUT = 30;
    31 
    32         /**
    33          * Threshold used to signal clients to send a compaction update.
    34          *
    35          * @since 7.0.0
    36          * @var int
    37          */
    38         const COMPACTION_THRESHOLD = 50;
    39 
    40         /**
    41          * Maximum total size (in bytes) of the request body.
    42          *
    43          * @since 7.0.0
    44          * @var int
    45          */
    46         const MAX_BODY_SIZE = 16 * MB_IN_BYTES;
    47 
    48         /**
    49          * Maximum number of rooms allowed per request.
    50          *
    51          * @since 7.0.0
    52          * @var int
    53          */
    54         const MAX_ROOMS_PER_REQUEST = 50;
    55 
    56         /**
    57          * Maximum length of a single update data string.
    58          *
    59          * @since 7.0.0
    60          * @var int
    61          */
    62         const MAX_UPDATE_DATA_SIZE = MB_IN_BYTES;
    63 
    64         /**
    65          * Sync update type: compaction.
    66          *
    67          * @since 7.0.0
    68          * @var string
    69          */
    70         const UPDATE_TYPE_COMPACTION = 'compaction';
    71 
    72         /**
    73          * Sync update type: sync step 1.
    74          *
    75          * @since 7.0.0
    76          * @var string
    77          */
    78         const UPDATE_TYPE_SYNC_STEP1 = 'sync_step1';
    79 
    80         /**
    81          * Sync update type: sync step 2.
    82          *
    83          * @since 7.0.0
    84          * @var string
    85          */
    86         const UPDATE_TYPE_SYNC_STEP2 = 'sync_step2';
    87 
    88         /**
    89          * Sync update type: regular update.
    90          *
    91          * @since 7.0.0
    92          * @var string
    93          */
    94         const UPDATE_TYPE_UPDATE = 'update';
    95 
    96         /**
    97          * Storage backend for sync updates.
    98          *
    99          * @since 7.0.0
    100          */
    101         private WP_Sync_Storage $storage;
    102 
    103         /**
    104          * Constructor.
    105          *
    106          * @since 7.0.0
    107          *
    108          * @param WP_Sync_Storage $storage Storage backend for sync updates.
    109          */
    110         public function __construct( WP_Sync_Storage $storage ) {
    111                 $this->storage = $storage;
    112         }
    113 
    114         /**
    115          * Registers REST API routes.
    116          *
    117          * @since 7.0.0
    118          */
    119         public function register_routes(): void {
    120                 $typed_update_args = array(
    121                         'properties' => array(
    122                                 'data' => array(
    123                                         'type'      => 'string',
    124                                         'required'  => true,
    125                                         'maxLength' => self::MAX_UPDATE_DATA_SIZE,
    126                                 ),
    127                                 'type' => array(
    128                                         'type'     => 'string',
    129                                         'required' => true,
    130                                         'enum'     => array(
    131                                                 self::UPDATE_TYPE_COMPACTION,
    132                                                 self::UPDATE_TYPE_SYNC_STEP1,
    133                                                 self::UPDATE_TYPE_SYNC_STEP2,
    134                                                 self::UPDATE_TYPE_UPDATE,
    135                                         ),
    136                                 ),
    137                         ),
    138                         'required'   => true,
    139                         'type'       => 'object',
    140                 );
    141 
    142                 $room_args = array(
    143                         'after'     => array(
    144                                 'minimum'  => 0,
    145                                 'required' => true,
    146                                 'type'     => 'integer',
    147                         ),
    148                         'awareness' => array(
    149                                 'required' => true,
    150                                 'type'     => array( 'object', 'null' ),
    151                         ),
    152                         'client_id' => array(
    153                                 'minimum'  => 1,
    154                                 'required' => true,
    155                                 'type'     => 'integer',
    156                         ),
    157                         'room'      => array(
    158                                 'required' => true,
    159                                 'type'     => 'string',
    160                                 'pattern'  => '^[^/]+/[^/:]+(?::\\S+)?$',
    161                         ),
    162                         'updates'   => array(
    163                                 'items'    => $typed_update_args,
    164                                 'minItems' => 0,
    165                                 'required' => true,
    166                                 'type'     => 'array',
    167                         ),
    168                 );
    169 
    170                 register_rest_route(
    171                         self::REST_NAMESPACE,
    172                         '/updates',
    173                         array(
    174                                 'methods'             => array( WP_REST_Server::CREATABLE ),
    175                                 'callback'            => array( $this, 'handle_request' ),
    176                                 'permission_callback' => array( $this, 'check_permissions' ),
    177                                 'validate_callback'   => array( $this, 'validate_request' ),
    178                                 'args'                => array(
    179                                         'rooms' => array(
    180                                                 'items'    => array(
    181                                                         'properties' => $room_args,
    182                                                         'type'       => 'object',
    183                                                 ),
    184                                                 'maxItems' => self::MAX_ROOMS_PER_REQUEST,
    185                                                 'required' => true,
    186                                                 'type'     => 'array',
    187                                         ),
    188                                 ),
    189                         )
    190                 );
    191         }
    192 
    193         /**
    194          * Checks if the current user has permission to access a room.
    195          *
    196          * @since 7.0.0
    197          *
    198          * @param WP_REST_Request $request The REST request.
    199          * @return bool|WP_Error True if user has permission, otherwise WP_Error with details.
    200          */
    201         public function check_permissions( WP_REST_Request $request ) {
    202                 // Minimum cap check. Is user logged in with a contributor role or higher?
    203                 if ( ! current_user_can( 'edit_posts' ) ) {
    204                         return new WP_Error(
    205                                 'rest_cannot_edit',
    206                                 __( 'You do not have permission to perform this action' ),
    207                                 array( 'status' => rest_authorization_required_code() )
    208                         );
    209                 }
    210 
    211                 $rooms      = $request['rooms'];
    212                 $wp_user_id = get_current_user_id();
    213 
    214                 foreach ( $rooms as $room ) {
    215                         $client_id = $room['client_id'];
    216                         $room      = $room['room'];
    217 
    218                         // Check that the client_id is not already owned by another user.
    219                         $existing_awareness = $this->storage->get_awareness_state( $room );
    220                         foreach ( $existing_awareness as $entry ) {
    221                                 if ( $client_id === $entry['client_id'] && $wp_user_id !== $entry['wp_user_id'] ) {
    222                                         return new WP_Error(
    223                                                 'rest_cannot_edit',
    224                                                 __( 'Client ID is already in use by another user.' ),
    225                                                 array( 'status' => rest_authorization_required_code() )
    226                                         );
    227                                 }
    228                         }
    229 
    230                         $type_parts   = explode( '/', $room, 2 );
    231                         $object_parts = explode( ':', $type_parts[1] ?? '', 2 );
    232 
    233                         $entity_kind = $type_parts[0];
    234                         $entity_name = $object_parts[0];
    235                         $object_id   = $object_parts[1] ?? null;
    236 
    237                         if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) {
    238                                 return new WP_Error(
    239                                         'rest_cannot_edit',
    240                                         sprintf(
    241                                                 /* translators: %s: The room name encodes the current entity being synced. */
    242                                                 __( 'You do not have permission to sync this entity: %s.' ),
    243                                                 $room
    244                                         ),
    245                                         array( 'status' => rest_authorization_required_code() )
    246                                 );
    247                         }
    248                 }
    249 
    250                 return true;
    251         }
    252 
    253         /**
    254          * Validates that the request body does not exceed the maximum allowed size.
    255          *
    256          * Runs as the route-level validate_callback, after per-arg schema
    257          * validation has already passed.
    258          *
    259          * @since 7.0.0
    260          *
    261          * @param WP_REST_Request $request The REST request.
    262          * @return true|WP_Error True if valid, WP_Error if the body is too large.
    263          */
    264         public function validate_request( WP_REST_Request $request ) {
    265                 $body = $request->get_body();
    266                 if ( is_string( $body ) && strlen( $body ) > self::MAX_BODY_SIZE ) {
    267                         return new WP_Error(
    268                                 'rest_sync_body_too_large',
    269                                 __( 'Request body is too large.' ),
    270                                 array( 'status' => 413 )
    271                         );
    272                 }
    273 
    274                 return true;
    275         }
    276 
    277         /**
    278          * Handles request: stores sync updates and awareness data, and returns
    279          * updates the client is missing.
    280          *
    281          * @since 7.0.0
    282          *
    283          * @param WP_REST_Request $request The REST request.
    284          * @return WP_REST_Response|WP_Error Response object or error.
    285          */
    286         public function handle_request( WP_REST_Request $request ) {
    287                 $rooms    = $request['rooms'];
    288                 $response = array(
    289                         'rooms' => array(),
    290                 );
    291 
    292                 foreach ( $rooms as $room_request ) {
    293                         $awareness = $room_request['awareness'];
    294                         $client_id = $room_request['client_id'];
    295                         $cursor    = $room_request['after'];
    296                         $room      = $room_request['room'];
    297 
    298                         // Merge awareness state.
    299                         $merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness );
    300 
    301                         // The lowest client ID is nominated to perform compaction when needed.
    302                         $is_compactor = false;
    303                         if ( count( $merged_awareness ) > 0 ) {
    304                                 $is_compactor = min( array_keys( $merged_awareness ) ) === $client_id;
    305                         }
    306 
    307                         // Process each update according to its type.
    308                         foreach ( $room_request['updates'] as $update ) {
    309                                 $result = $this->process_sync_update( $room, $client_id, $cursor, $update );
    310                                 if ( is_wp_error( $result ) ) {
    311                                         return $result;
    312                                 }
    313                         }
    314 
    315                         // Get updates for this client.
    316                         $room_response              = $this->get_updates( $room, $client_id, $cursor, $is_compactor );
    317                         $room_response['awareness'] = $merged_awareness;
    318 
    319                         $response['rooms'][] = $room_response;
    320                 }
    321 
    322                 return new WP_REST_Response( $response, 200 );
    323         }
    324 
    325         /**
    326          * Checks if the current user can sync a specific entity type.
    327          *
    328          * @since 7.0.0
    329          *
    330          * @param string      $entity_kind The entity kind, e.g. 'postType', 'taxonomy', 'root'.
    331          * @param string      $entity_name The entity name, e.g. 'post', 'category', 'site'.
    332          * @param string|null $object_id   The numeric object ID / entity key for single entities, null for collections.
    333          * @return bool True if user has permission, otherwise false.
    334          */
    335         private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool {
    336                 if ( is_string( $object_id ) ) {
    337                         if ( ! ctype_digit( $object_id ) ) {
    338                                 return false;
    339                         }
    340                         $object_id = (int) $object_id;
    341                 }
    342                 if ( null !== $object_id && $object_id <= 0 ) {
    343                         // Object ID must be numeric if provided.
    344                         return false;
    345                 }
    346 
    347                 // Validate permissions for the provided object ID.
    348                 if ( is_int( $object_id ) ) {
    349                         // Handle single post type entities with a defined object ID.
    350                         if ( 'postType' === $entity_kind ) {
    351                                 if ( get_post_type( $object_id ) !== $entity_name ) {
    352                                         // Post is not of the specified post type.
    353                                         return false;
    354                                 }
    355                                 return current_user_can( 'edit_post', $object_id );
    356                         }
    357 
    358                         // Handle single taxonomy term entities with a defined object ID.
    359                         if ( 'taxonomy' === $entity_kind ) {
    360                                 $term_exists = term_exists( $object_id, $entity_name );
    361                                 if ( ! is_array( $term_exists ) || ! isset( $term_exists['term_id'] ) ) {
    362                                         // Either term doesn't exist OR term is not in specified taxonomy.
    363                                         return false;
    364                                 }
    365 
    366                                 return current_user_can( 'edit_term', $object_id );
    367                         }
    368 
    369                         // Handle single comment entities with a defined object ID.
    370                         if ( 'root' === $entity_kind && 'comment' === $entity_name ) {
    371                                 return current_user_can( 'edit_comment', $object_id );
    372                         }
    373                 }
    374 
    375                 // All the remaining checks are for collections. If an object ID is provided,
    376                 // reject the request.
    377                 if ( null !== $object_id ) {
    378                         return false;
    379                 }
    380 
    381                 // For postType collections, check if the user can edit posts of this type.
    382                 if ( 'postType' === $entity_kind ) {
    383                         $post_type_object = get_post_type_object( $entity_name );
    384                         if ( ! isset( $post_type_object->cap->edit_posts ) ) {
    385                                 return false;
    386                         }
    387 
    388                         return current_user_can( $post_type_object->cap->edit_posts );
    389                 }
    390 
    391                 // Collection syncing does not exchange entity data. It only signals if
    392                 // another user has updated an entity in the collection. Therefore, we only
    393                 // compare against an allow list of collection types.
    394                 $allowed_collection_entity_kinds = array(
    395                         'postType',
    396                         'root',
    397                         'taxonomy',
    398                 );
    399 
    400                 return in_array( $entity_kind, $allowed_collection_entity_kinds, true );
    401         }
    402 
    403         /**
    404          * Processes and stores an awareness update from a client.
    405          *
    406          * @since 7.0.0
    407          *
    408          * @param string                    $room             Room identifier.
    409          * @param int                       $client_id        Client identifier.
    410          * @param array<string, mixed>|null $awareness_update Awareness state sent by the client.
    411          * @return array<int, array<string, mixed>> Map of client ID to awareness state.
    412          */
    413         private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array {
    414                 $existing_awareness = $this->storage->get_awareness_state( $room );
    415                 $updated_awareness  = array();
    416                 $current_time       = time();
    417 
    418                 foreach ( $existing_awareness as $entry ) {
    419                         // Remove this client's entry (it will be updated below).
    420                         if ( $client_id === $entry['client_id'] ) {
    421                                 continue;
    422                         }
    423 
    424                         // Remove entries that have expired.
    425                         if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) {
    426                                 continue;
    427                         }
    428 
    429                         $updated_awareness[] = $entry;
    430                 }
    431 
    432                 // Add this client's awareness state.
    433                 if ( null !== $awareness_update ) {
    434                         $updated_awareness[] = array(
    435                                 'client_id'  => $client_id,
    436                                 'state'      => $awareness_update,
    437                                 'updated_at' => $current_time,
    438                                 'wp_user_id' => get_current_user_id(),
    439                         );
    440                 }
    441 
    442                 // This action can fail, but it shouldn't fail the entire request.
    443                 $this->storage->set_awareness_state( $room, $updated_awareness );
    444 
    445                 // Convert to client_id => state map for response.
    446                 $response = array();
    447                 foreach ( $updated_awareness as $entry ) {
    448                         $response[ $entry['client_id'] ] = $entry['state'];
    449                 }
    450 
    451                 return $response;
    452         }
    453 
    454         /**
    455          * Processes a sync update based on its type.
    456          *
    457          * @since 7.0.0
    458          *
    459          * @param string                            $room      Room identifier.
    460          * @param int                               $client_id Client identifier.
    461          * @param int                               $cursor    Client cursor (marker of last seen update).
    462          * @param array{data: string, type: string} $update    Sync update.
    463          * @return true|WP_Error True on success, WP_Error on storage failure.
    464          */
    465         private function process_sync_update( string $room, int $client_id, int $cursor, array $update ) {
    466                 $data = $update['data'];
    467                 $type = $update['type'];
    468 
    469                 switch ( $type ) {
    470                         case self::UPDATE_TYPE_COMPACTION:
    471                                 /*
    472                                  * Compaction replaces updates the client has already seen. Only remove
    473                                  * updates with markers before the client's cursor to preserve updates
    474                                  * that arrived since the client's last sync.
    475                                  *
    476                                  * Check for a newer compaction update first. If one exists, skip this
    477                                  * compaction to avoid overwriting it.
    478                                  */
    479                                 $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor );
    480                                 $has_newer_compaction = false;
    481 
    482                                 foreach ( $updates_after_cursor as $existing ) {
    483                                         if ( self::UPDATE_TYPE_COMPACTION === $existing['type'] ) {
    484                                                 $has_newer_compaction = true;
    485                                                 break;
    486                                         }
    487                                 }
    488 
    489                                 if ( ! $has_newer_compaction ) {
    490                                         if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) {
    491                                                 return new WP_Error(
    492                                                         'rest_sync_storage_error',
    493                                                         __( 'Failed to remove updates during compaction.' ),
    494                                                         array( 'status' => 500 )
    495                                                 );
    496                                         }
    497 
    498                                         return $this->add_update( $room, $client_id, $type, $data );
    499                                 }
    500 
    501                                 /*
    502                                  * A newer compaction already advanced the cursor, but we
    503                                  * can not safely drop an update. The incoming bytes still encode
    504                                  * operations other clients may not have seen, so store them as a
    505                                  * regular update. Y.applyUpdateV2 merges state-as-update blobs
    506                                  * idempotently, so overlap with the existing compaction is safe.
    507                                  */
    508                                 return $this->add_update( $room, $client_id, self::UPDATE_TYPE_UPDATE, $data );
    509 
    510                         case self::UPDATE_TYPE_SYNC_STEP1:
    511                         case self::UPDATE_TYPE_SYNC_STEP2:
    512                         case self::UPDATE_TYPE_UPDATE:
    513                                 /*
    514                                  * Sync step 1 announces a client's state vector. Other clients need
    515                                  * to see it so they can respond with sync_step2 containing missing
    516                                  * updates. The cursor-based filtering prevents re-delivery.
    517                                  *
    518                                  * Sync step 2 contains updates for a specific client.
    519                                  *
    520                                  * All updates are stored persistently.
    521                                  */
    522                                 return $this->add_update( $room, $client_id, $type, $data );
    523                 }
    524 
    525                 return new WP_Error(
    526                         'rest_invalid_update_type',
    527                         __( 'Invalid sync update type.' ),
    528                         array( 'status' => 400 )
    529                 );
    530         }
    531 
    532         /**
    533          * Adds an update to a room's update list via storage.
    534          *
    535          * @since 7.0.0
    536          *
    537          * @param string $room      Room identifier.
    538          * @param int    $client_id Client identifier.
    539          * @param string $type      Update type (sync_step1, sync_step2, update, compaction).
    540          * @param string $data      Base64-encoded update data.
    541          * @return true|WP_Error True on success, WP_Error on storage failure.
    542          */
    543         private function add_update( string $room, int $client_id, string $type, string $data ) {
    544                 $update = array(
    545                         'client_id' => $client_id,
    546                         'data'      => $data,
    547                         'type'      => $type,
    548                 );
    549 
    550                 if ( ! $this->storage->add_update( $room, $update ) ) {
    551                         return new WP_Error(
    552                                 'rest_sync_storage_error',
    553                                 __( 'Failed to store sync update.' ),
    554                                 array( 'status' => 500 )
    555                         );
    556                 }
    557 
    558                 return true;
    559         }
    560 
    561         /**
    562          * Gets sync updates for a specific client from a room after a given cursor.
    563          *
    564          * Delegates cursor-based retrieval to the storage layer, then applies
    565          * client-specific filtering and compaction logic.
    566          *
    567          * @since 7.0.0
    568          *
    569          * @param string $room         Room identifier.
    570          * @param int    $client_id    Client identifier.
    571          * @param int    $cursor       Return updates after this cursor.
    572          * @param bool   $is_compactor True if this client is nominated to perform compaction.
    573          * @return array{
    574          *   end_cursor: int,
    575          *   should_compact: bool,
    576          *   room: string,
    577          *   total_updates: int,
    578          *   updates: array<int, array{data: string, type: string}>,
    579          * } Response data for this room.
    580          */
    581         private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array {
    582                 $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor );
    583                 $total_updates        = $this->storage->get_update_count( $room );
    584 
    585                 // Filter out this client's updates, except compaction updates.
    586                 $typed_updates = array();
    587                 foreach ( $updates_after_cursor as $update ) {
    588                         if ( $client_id === $update['client_id'] && self::UPDATE_TYPE_COMPACTION !== $update['type'] ) {
    589                                 continue;
    590                         }
    591 
    592                         $typed_updates[] = array(
    593                                 'data' => $update['data'],
    594                                 'type' => $update['type'],
    595                         );
    596                 }
    597 
    598                 $should_compact = $is_compactor && $total_updates > self::COMPACTION_THRESHOLD;
    599 
    600                 return array(
    601                         'end_cursor'     => $this->storage->get_cursor( $room ),
    602                         'room'           => $room,
    603                         'should_compact' => $should_compact,
    604                         'total_updates'  => $total_updates,
    605                         'updates'        => $typed_updates,
    606                 );
    607         }
    608 }
  • trunk/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php

    r62099 r62334  
    1 <?php
    2 /**
    3  * WP_Sync_Post_Meta_Storage class
    4  *
    5  * @package WordPress
    6  */
    7 
    8 /**
    9  * Core class that provides an interface for storing and retrieving sync
    10  * updates and awareness data during a collaborative session.
    11  *
    12  * Data is stored as post meta on a dedicated post per room of a custom post type.
    13  *
    14  * @since 7.0.0
    15  *
    16  * @access private
    17  */
    18 class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage {
    19         /**
    20          * Post type for sync storage.
    21          *
    22          * @since 7.0.0
    23          * @var string
    24          */
    25         const POST_TYPE = 'wp_sync_storage';
    26 
    27         /**
    28          * Meta key for awareness state.
    29          *
    30          * @since 7.0.0
    31          * @var string
    32          */
    33         const AWARENESS_META_KEY = 'wp_sync_awareness_state';
    34 
    35         /**
    36          * Meta key for sync updates.
    37          *
    38          * @since 7.0.0
    39          * @var string
    40          */
    41         const SYNC_UPDATE_META_KEY = 'wp_sync_update_data';
    42 
    43         /**
    44          * Cache of cursors by room.
    45          *
    46          * @since 7.0.0
    47          * @var array<string, int>
    48          */
    49         private array $room_cursors = array();
    50 
    51         /**
    52          * Cache of update counts by room.
    53          *
    54          * @since 7.0.0
    55          * @var array<string, int>
    56          */
    57         private array $room_update_counts = array();
    58 
    59         /**
    60          * Cache of storage post IDs by room hash.
    61          *
    62          * @since 7.0.0
    63          * @var array<string, int>
    64          */
    65         private static array $storage_post_ids = array();
    66 
    67         /**
    68          * Adds a sync update to a given room.
    69          *
    70          * @since 7.0.0
    71          *
    72          * @global wpdb $wpdb WordPress database abstraction object.
    73          *
    74          * @param string $room   Room identifier.
    75          * @param mixed  $update Sync update.
    76          * @return bool True on success, false on failure.
    77          */
    78         public function add_update( string $room, $update ): bool {
    79                 global $wpdb;
    80 
    81                 $post_id = $this->get_storage_post_id( $room );
    82                 if ( null === $post_id ) {
    83                         return false;
    84                 }
    85 
    86                 // Use direct database operation to avoid cache invalidation performed by
    87                 // post meta functions (`wp_cache_set_posts_last_changed()` and direct
    88                 // `wp_cache_delete()` calls).
    89                 return (bool) $wpdb->insert(
    90                         $wpdb->postmeta,
    91                         array(
    92                                 'post_id'    => $post_id,
    93                                 'meta_key'   => self::SYNC_UPDATE_META_KEY,
    94                                 'meta_value' => wp_json_encode( $update ),
    95                         ),
    96                         array( '%d', '%s', '%s' )
    97                 );
    98         }
    99 
    100         /**
    101          * Gets awareness state for a given room.
    102          *
    103          * @since 7.0.0
    104          *
    105          * @global wpdb $wpdb WordPress database abstraction object.
    106          *
    107          * @param string $room Room identifier.
    108          * @return array<int, mixed> Awareness state.
    109          */
    110         public function get_awareness_state( string $room ): array {
    111                 global $wpdb;
    112 
    113                 $post_id = $this->get_storage_post_id( $room );
    114                 if ( null === $post_id ) {
    115                         return array();
    116                 }
    117 
    118                 // Use direct database operation to avoid updating the post meta cache.
    119                 // ORDER BY meta_id DESC ensures the latest row wins if duplicates exist
    120                 // from a past race condition in set_awareness_state().
    121                 $meta_value = $wpdb->get_var(
    122                         $wpdb->prepare(
    123                                 "SELECT meta_value FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1",
    124                                 $post_id,
    125                                 self::AWARENESS_META_KEY
    126                         )
    127                 );
    128 
    129                 if ( null === $meta_value ) {
    130                         return array();
    131                 }
    132 
    133                 $awareness = json_decode( $meta_value, true );
    134 
    135                 if ( ! is_array( $awareness ) ) {
    136                         return array();
    137                 }
    138 
    139                 return array_values( $awareness );
    140         }
    141 
    142         /**
    143          * Sets awareness state for a given room.
    144          *
    145          * @since 7.0.0
    146          *
    147          * @global wpdb $wpdb WordPress database abstraction object.
    148          *
    149          * @param string            $room      Room identifier.
    150          * @param array<int, mixed> $awareness Serializable awareness state.
    151          * @return bool True on success, false on failure.
    152          */
    153         public function set_awareness_state( string $room, array $awareness ): bool {
    154                 global $wpdb;
    155 
    156                 $post_id = $this->get_storage_post_id( $room );
    157                 if ( null === $post_id ) {
    158                         return false;
    159                 }
    160 
    161                 // Use direct database operation to avoid cache invalidation performed by
    162                 // post meta functions (`wp_cache_set_posts_last_changed()` and direct
    163                 // `wp_cache_delete()` calls).
    164                 //
    165                 // If two concurrent requests both see no row and both INSERT, the
    166                 // duplicate is harmless: get_awareness_state() reads the latest row
    167                 // (ORDER BY meta_id DESC).
    168                 $meta_id = $wpdb->get_var(
    169                         $wpdb->prepare(
    170                                 "SELECT meta_id FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1",
    171                                 $post_id,
    172                                 self::AWARENESS_META_KEY
    173                         )
    174                 );
    175 
    176                 if ( $meta_id ) {
    177                         return (bool) $wpdb->update(
    178                                 $wpdb->postmeta,
    179                                 array( 'meta_value' => wp_json_encode( $awareness ) ),
    180                                 array( 'meta_id' => $meta_id ),
    181                                 array( '%s' ),
    182                                 array( '%d' )
    183                         );
    184                 }
    185 
    186                 return (bool) $wpdb->insert(
    187                         $wpdb->postmeta,
    188                         array(
    189                                 'post_id'    => $post_id,
    190                                 'meta_key'   => self::AWARENESS_META_KEY,
    191                                 'meta_value' => wp_json_encode( $awareness ),
    192                         ),
    193                         array( '%d', '%s', '%s' )
    194                 );
    195         }
    196 
    197         /**
    198          * Gets the current cursor for a given room.
    199          *
    200          * The cursor is set during get_updates_after_cursor() and represents the
    201          * highest meta_id seen for the room's sync updates.
    202          *
    203          * @since 7.0.0
    204          *
    205          * @param string $room Room identifier.
    206          * @return int Current cursor for the room.
    207          */
    208         public function get_cursor( string $room ): int {
    209                 return $this->room_cursors[ $room ] ?? 0;
    210         }
    211 
    212         /**
    213          * Gets or creates the storage post for a given room.
    214          *
    215          * Each room gets its own dedicated post so that post meta cache
    216          * invalidation is scoped to a single room rather than all of them.
    217          *
    218          * @since 7.0.0
    219          *
    220          * @param string $room Room identifier.
    221          * @return int|null Post ID.
    222          */
    223         private function get_storage_post_id( string $room ): ?int {
    224                 $room_hash = md5( $room );
    225 
    226                 if ( isset( self::$storage_post_ids[ $room_hash ] ) ) {
    227                         return self::$storage_post_ids[ $room_hash ];
    228                 }
    229 
    230                 // Try to find an existing post for this room.
    231                 $posts = get_posts(
    232                         array(
    233                                 'post_type'      => self::POST_TYPE,
    234                                 'posts_per_page' => 1,
    235                                 'post_status'    => 'publish',
    236                                 'name'           => $room_hash,
    237                                 'fields'         => 'ids',
    238                                 'orderby'        => 'ID',
    239                                 'order'          => 'ASC',
    240                         )
    241                 );
    242 
    243                 $post_id = array_first( $posts );
    244                 if ( is_int( $post_id ) ) {
    245                         self::$storage_post_ids[ $room_hash ] = $post_id;
    246                         return $post_id;
    247                 }
    248 
    249                 // Create new post for this room.
    250                 $post_id = wp_insert_post(
    251                         array(
    252                                 'post_type'   => self::POST_TYPE,
    253                                 'post_status' => 'publish',
    254                                 'post_title'  => 'Sync Storage',
    255                                 'post_name'   => $room_hash,
    256                         )
    257                 );
    258 
    259                 if ( is_int( $post_id ) ) {
    260                         self::$storage_post_ids[ $room_hash ] = $post_id;
    261                         return $post_id;
    262                 }
    263 
    264                 return null;
    265         }
    266 
    267         /**
    268          * Gets the number of updates stored for a given room.
    269          *
    270          * @since 7.0.0
    271          *
    272          * @param string $room Room identifier.
    273          * @return int Number of updates stored for the room.
    274          */
    275         public function get_update_count( string $room ): int {
    276                 return $this->room_update_counts[ $room ] ?? 0;
    277         }
    278 
    279         /**
    280          * Retrieves sync updates from a room after the given cursor.
    281          *
    282          * @since 7.0.0
    283          *
    284          * @global wpdb $wpdb WordPress database abstraction object.
    285          *
    286          * @param string $room   Room identifier.
    287          * @param int    $cursor Return updates after this cursor (meta_id).
    288          * @return array<int, mixed> Sync updates.
    289          */
    290         public function get_updates_after_cursor( string $room, int $cursor ): array {
    291                 global $wpdb;
    292 
    293                 $post_id = $this->get_storage_post_id( $room );
    294                 if ( null === $post_id ) {
    295                         $this->room_cursors[ $room ]       = 0;
    296                         $this->room_update_counts[ $room ] = 0;
    297                         return array();
    298                 }
    299 
    300                 // Capture the current room state first so the returned cursor is race-safe.
    301                 $stats = $wpdb->get_row(
    302                         $wpdb->prepare(
    303                                 "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",
    304                                 $post_id,
    305                                 self::SYNC_UPDATE_META_KEY
    306                         )
    307                 );
    308 
    309                 $total_updates = $stats ? (int) $stats->total_updates : 0;
    310                 $max_meta_id   = $stats ? (int) $stats->max_meta_id : 0;
    311 
    312                 $this->room_update_counts[ $room ] = $total_updates;
    313                 $this->room_cursors[ $room ]       = $max_meta_id;
    314 
    315                 if ( $max_meta_id <= $cursor ) {
    316                         return array();
    317                 }
    318 
    319                 $rows = $wpdb->get_results(
    320                         $wpdb->prepare(
    321                                 "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",
    322                                 $post_id,
    323                                 self::SYNC_UPDATE_META_KEY,
    324                                 $cursor,
    325                                 $max_meta_id
    326                         )
    327                 );
    328 
    329                 if ( ! $rows ) {
    330                         return array();
    331                 }
    332 
    333                 $updates = array();
    334                 foreach ( $rows as $row ) {
    335                         $decoded = json_decode( $row->meta_value, true );
    336                         if ( null !== $decoded ) {
    337                                 $updates[] = $decoded;
    338                         }
    339                 }
    340 
    341                 return $updates;
    342         }
    343 
    344         /**
    345          * Removes updates from a room that are older than the given cursor.
    346          *
    347          * @since 7.0.0
    348          *
    349          * @global wpdb $wpdb WordPress database abstraction object.
    350          *
    351          * @param string $room   Room identifier.
    352          * @param int    $cursor Remove updates with meta_id < this cursor.
    353          * @return bool True on success, false on failure.
    354          */
    355         public function remove_updates_before_cursor( string $room, int $cursor ): bool {
    356                 global $wpdb;
    357 
    358                 $post_id = $this->get_storage_post_id( $room );
    359                 if ( null === $post_id ) {
    360                         return false;
    361                 }
    362 
    363                 $deleted_rows = $wpdb->query(
    364                         $wpdb->prepare(
    365                                 "DELETE FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id < %d",
    366                                 $post_id,
    367                                 self::SYNC_UPDATE_META_KEY,
    368                                 $cursor
    369                         )
    370                 );
    371 
    372                 if ( false === $deleted_rows ) {
    373                         return false;
    374                 }
    375 
    376                 return true;
    377         }
    378 }
  • trunk/src/wp-includes/collaboration/interface-wp-sync-storage.php

    r61689 r62334  
    1 <?php
    2 /**
    3  * WP_Sync_Storage interface
    4  *
    5  * @package WordPress
    6  */
    7 
    8 interface WP_Sync_Storage {
    9         /**
    10          * Adds a sync update to a given room.
    11          *
    12          * @since 7.0.0
    13          *
    14          * @param string $room   Room identifier.
    15          * @param mixed  $update Serializable sync update, opaque to the storage implementation.
    16          * @return bool True on success, false on failure.
    17          */
    18         public function add_update( string $room, $update ): bool;
    19 
    20         /**
    21          * Gets awareness state for a given room.
    22          *
    23          * @since 7.0.0
    24          *
    25          * @param string $room Room identifier.
    26          * @return array<int, mixed> Awareness state.
    27          */
    28         public function get_awareness_state( string $room ): array;
    29 
    30         /**
    31          * Gets the current cursor for a given room. This should return a monotonically
    32          * increasing integer that represents the last update that was returned for the
    33          * room during the current request. This allows clients to retrieve updates
    34          * after a specific cursor on subsequent requests.
    35          *
    36          * @since 7.0.0
    37          *
    38          * @param string $room Room identifier.
    39          * @return int Current cursor for the room.
    40          */
    41         public function get_cursor( string $room ): int;
    42 
    43         /**
    44          * Gets the total number of stored updates for a given room.
    45          *
    46          * @since 7.0.0
    47          *
    48          * @param string $room Room identifier.
    49          * @return int Total number of updates.
    50          */
    51         public function get_update_count( string $room ): int;
    52 
    53         /**
    54          * Retrieves sync updates from a room for a given client and cursor. Updates
    55          * from the specified client should be excluded.
    56          *
    57          * @since 7.0.0
    58          *
    59          * @param string $room   Room identifier.
    60          * @param int    $cursor Return updates after this cursor.
    61          * @return array<int, mixed> Sync updates.
    62          */
    63         public function get_updates_after_cursor( string $room, int $cursor ): array;
    64 
    65         /**
    66          * Removes updates from a room that are older than the provided cursor.
    67          *
    68          * @since 7.0.0
    69          *
    70          * @param string $room   Room identifier.
    71          * @param int    $cursor Remove updates with markers < this cursor.
    72          * @return bool True on success, false on failure.
    73          */
    74         public function remove_updates_before_cursor( string $room, int $cursor ): bool;
    75 
    76         /**
    77          * Sets awareness state for a given room.
    78          *
    79          * @since 7.0.0
    80          *
    81          * @param string            $room      Room identifier.
    82          * @param array<int, mixed> $awareness Serializable awareness state.
    83          * @return bool True on success, false on failure.
    84          */
    85         public function set_awareness_state( string $room, array $awareness ): bool;
    86 }
  • trunk/src/wp-includes/default-filters.php

    r62233 r62334  
    791791add_action( 'init', '_wp_register_default_font_collections' );
    792792
    793 // Collaboration.
    794 add_action( 'admin_init', 'wp_collaboration_inject_setting' );
    795 
    796793// Add ignoredHookedBlocks metadata attribute to the template and template part post types.
    797794add_filter( 'rest_pre_insert_wp_template', 'inject_ignored_hooked_blocks_metadata_attributes' );
  • trunk/src/wp-includes/option.php

    r62058 r62334  
    28872887
    28882888        register_setting(
    2889                 'writing',
    2890                 'wp_collaboration_enabled',
    2891                 array(
    2892                         'type'              => 'boolean',
    2893                         'description'       => __( 'Enable Real-Time Collaboration' ),
    2894                         'sanitize_callback' => 'rest_sanitize_boolean',
    2895                         'default'           => false,
    2896                         'show_in_rest'      => true,
    2897                 )
    2898         );
    2899 
    2900         register_setting(
    29012889                'reading',
    29022890                'posts_per_page',
  • trunk/src/wp-includes/post.php

    r62178 r62334  
    657657                )
    658658        );
    659 
    660         if ( wp_is_collaboration_enabled() ) {
    661                 register_post_type(
    662                         'wp_sync_storage',
    663                         array(
    664                                 'labels'             => array(
    665                                         'name'          => __( 'Sync Updates' ),
    666                                         'singular_name' => __( 'Sync Update' ),
    667                                 ),
    668                                 'public'             => false,
    669                                 '_builtin'           => true, /* internal use only. don't use this when registering your own post type. */
    670                                 'hierarchical'       => false,
    671                                 'capabilities'       => array(
    672                                         'read'                   => 'do_not_allow',
    673                                         'read_private_posts'     => 'do_not_allow',
    674                                         'create_posts'           => 'do_not_allow',
    675                                         'publish_posts'          => 'do_not_allow',
    676                                         'edit_posts'             => 'do_not_allow',
    677                                         'edit_others_posts'      => 'do_not_allow',
    678                                         'edit_published_posts'   => 'do_not_allow',
    679                                         'delete_posts'           => 'do_not_allow',
    680                                         'delete_others_posts'    => 'do_not_allow',
    681                                         'delete_published_posts' => 'do_not_allow',
    682                                 ),
    683                                 'map_meta_cap'       => false,
    684                                 'publicly_queryable' => false,
    685                                 'query_var'          => false,
    686                                 'rewrite'            => false,
    687                                 'show_in_menu'       => false,
    688                                 'show_in_rest'       => false,
    689                                 'show_ui'            => false,
    690                                 'can_export'         => false,
    691                                 'supports'           => array( 'custom-fields' ),
    692                         )
    693                 );
    694         }
    695659
    696660        register_post_status(
     
    86568620 *
    86578621 * @since 6.3.0 Adds `wp_pattern_sync_status` meta field to the wp_block post type so an unsynced option can be added.
    8658  * @since 7.0.0 Adds `_crdt_document` meta field to post types so that CRDT documents can be persisted.
    86598622 *
    86608623 * @link https://github.com/WordPress/gutenberg/pull/51144
     
    86768639                )
    86778640        );
    8678 
    8679         if ( wp_is_collaboration_enabled() ) {
    8680                 register_meta(
    8681                         'post',
    8682                         '_crdt_document',
    8683                         array(
    8684                                 'auth_callback'     => static function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool {
    8685                                         return user_can( $user_id, 'edit_post', $object_id );
    8686                                 },
    8687                                 /*
    8688                                  * Revisions must be disabled because we always want to preserve
    8689                                  * the latest persisted CRDT document, even when a revision is restored.
    8690                                  * This ensures that we can continue to apply updates to a shared document
    8691                                  * and peers can simply merge the restored revision like any other incoming
    8692                                  * update.
    8693                                  *
    8694                                  * If we want to persist CRDT documents alongside revisions in the
    8695                                  * future, we should do so in a separate meta key.
    8696                                  */
    8697                                 'revisions_enabled' => false,
    8698                                 'show_in_rest'      => true,
    8699                                 'single'            => true,
    8700                                 'type'              => 'string',
    8701                         )
    8702                 );
    8703         }
    8704 }
     8641}
  • trunk/src/wp-includes/rest-api.php

    r62107 r62334  
    429429        $icons_controller = new WP_REST_Icons_Controller();
    430430        $icons_controller->register_routes();
    431 
    432         // Collaboration.
    433         if ( wp_is_collaboration_enabled() ) {
    434                 $sync_storage = new WP_Sync_Post_Meta_Storage();
    435                 $sync_server  = new WP_HTTP_Polling_Sync_Server( $sync_storage );
    436                 $sync_server->register_routes();
    437         }
    438431}
    439432
  • trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php

    r62311 r62334  
    230230                }
    231231
    232                 $post_lock_is_active      = wp_check_post_lock( $post->ID );
    233                 $is_auto_draft            = 'auto-draft' === $post->post_status;
    234                 $is_draft                 = 'draft' === $post->post_status || $is_auto_draft;
    235                 $is_collaboration_enabled = wp_is_collaboration_enabled();
     232                $post_lock_is_active = wp_check_post_lock( $post->ID );
     233                $is_draft            = 'draft' === $post->post_status || 'auto-draft' === $post->post_status;
    236234
    237235                /*
    238236                 * When a post is still in draft form, updates from the author can directly update the post.
    239237                 * Other autosaves must be stored as per-user autosave revisions.
    240                  *
    241                  * When RTC is active, however, regular draft autosaves must not update the parent post directly.
    242                  * Since all peers are sharing a persisted editing state (a shared CRDT), it’s important that
    243                  * they all store updates in a revision. If edits were applied to the post, then upon the next
    244                  * editor reload, it would appear as though the post had been updated externally, and those same
    245                  * changes would be re-applied to the CRDT, duplicating the edits.
    246                  *
    247                  * The one caveat for RTC is that the first peer to store an edit must promote an auto-draft
    248                  * into a real draft post. If this doesn’t happen then the peers may continue to make edits
    249                  * but the draft will be lost, as auto-drafts are not listed in post views.
    250238                 */
    251239                $can_update_author_draft_post = (
    252240                        $is_draft &&
    253                         (int) $post->post_author === $user_id &&
    254                         ! $is_collaboration_enabled
     241                        (int) $post->post_author === $user_id
    255242                );
    256243
    257                 $can_promote_auto_draft_post = (
    258                         $is_auto_draft &&
    259                         $is_collaboration_enabled &&
    260                         current_user_can( 'edit_post', $post->ID )
     244                $should_update_parent_draft_post = (
     245                        ! $post_lock_is_active && $can_update_author_draft_post
    261246                );
    262247
    263                 $should_update_parent_draft_post = (
    264                         $can_promote_auto_draft_post ||
    265                         ( ! $post_lock_is_active && $can_update_author_draft_post )
    266                 );
    267 
    268248                if ( $should_update_parent_draft_post ) {
     249                        /*
     250                         * Draft posts for the same author: autosaving updates the post and does not create a revision.
     251                         * Convert the post object to an array and add slashes, wp_update_post() expects escaped array.
     252                         */
    269253                        $autosave_id = wp_update_post( wp_slash( (array) $prepared_post ), true );
    270254                } else {
  • trunk/src/wp-settings.php

    r61943 r62334  
    311311require ABSPATH . WPINC . '/abilities-api.php';
    312312require ABSPATH . WPINC . '/abilities.php';
    313 require ABSPATH . WPINC . '/collaboration/interface-wp-sync-storage.php';
    314 require ABSPATH . WPINC . '/collaboration/class-wp-sync-post-meta-storage.php';
    315 require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-sync-server.php';
    316 require ABSPATH . WPINC . '/collaboration.php';
    317313require ABSPATH . WPINC . '/rest-api.php';
    318314require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php';
  • trunk/tests/phpunit/tests/collaboration/restAutosavesController.php

    r62311 r62334  
    1 <?php
    2 /**
    3  * Tests for the collaboration autosaves REST controller override.
    4  *
    5  * @package WordPress
    6  * @subpackage Collaboration
    7  *
    8  * @group collaboration
    9  * @group restapi
    10  */
    11 class Tests_Collaboration_RestAutosavesController extends WP_UnitTestCase {
    12 
    13         protected static int $author_id;
    14         protected static int $editor_id;
    15 
    16         public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
    17                 self::$author_id = $factory->user->create( array( 'role' => 'author' ) );
    18                 self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) );
    19         }
    20 
    21         public static function wpTearDownAfterClass() {
    22                 self::delete_user( self::$author_id );
    23                 self::delete_user( self::$editor_id );
    24                 delete_option( 'wp_collaboration_enabled' );
    25         }
    26 
    27         public function set_up() {
    28                 parent::set_up();
    29                 wp_set_current_user( self::$author_id );
    30         }
    31 
    32         /**
    33          * Creates an empty auto-draft post.
    34          *
    35          * @return int Post ID.
    36          */
    37         private function create_auto_draft(): int {
    38                 return self::factory()->post->create(
    39                         array(
    40                                 'post_author'  => self::$author_id,
    41                                 'post_content' => '',
    42                                 'post_status'  => 'auto-draft',
    43                                 'post_title'   => 'Auto Draft',
    44                                 'post_type'    => 'post',
    45                         )
    46                 );
    47         }
    48 
    49         /**
    50          * Creates a draft post.
    51          *
    52          * @param string $title   Post title.
    53          * @param string $content Post content.
    54          * @return int Post ID.
    55          */
    56         private function create_draft( string $title, string $content ): int {
    57                 return self::factory()->post->create(
    58                         array(
    59                                 'post_author'  => self::$author_id,
    60                                 'post_content' => $content,
    61                                 'post_status'  => 'draft',
    62                                 'post_title'   => $title,
    63                                 'post_type'    => 'post',
    64                         )
    65                 );
    66         }
    67 
    68         /**
    69          * Dispatches an autosave request for a post.
    70          *
    71          * @param int    $post_id Post ID.
    72          * @param string $title   Autosaved post title.
    73          * @param string $content Autosaved post content.
    74          * @return WP_REST_Response Autosave response.
    75          */
    76         private function dispatch_autosave( int $post_id, string $title, string $content ): WP_REST_Response {
    77                 $request = new WP_REST_Request( 'POST', "/wp/v2/posts/{$post_id}/autosaves" );
    78                 $request->set_param( 'title', $title );
    79                 $request->set_param( 'content', $content );
    80                 $request->set_param( 'status', 'draft' );
    81 
    82                 return rest_get_server()->dispatch( $request );
    83         }
    84 
    85         /**
    86          * @ticket 65138
    87          */
    88         public function test_auto_draft_autosave_promotes_parent_post_when_collaboration_is_disabled() {
    89                 update_option( 'wp_collaboration_enabled', 0 );
    90 
    91                 $post_id = $this->create_auto_draft();
    92                 $title   = 'No RTC autosaved title';
    93                 $content = '<!-- wp:paragraph --><p>No RTC autosaved content</p><!-- /wp:paragraph -->';
    94 
    95                 $response = $this->dispatch_autosave( $post_id, $title, $content );
    96 
    97                 $this->assertSame( 200, $response->get_status() );
    98                 $post = get_post( $post_id );
    99                 $this->assertSame( 'draft', $post->post_status );
    100                 $this->assertSame( $title, $post->post_title );
    101                 $this->assertSame( $content, $post->post_content );
    102         }
    103 
    104         /**
    105          * @ticket 65138
    106          */
    107         public function test_auto_draft_autosave_promotes_parent_post_when_collaboration_is_enabled() {
    108                 update_option( 'wp_collaboration_enabled', 1 );
    109 
    110                 $post_id = $this->create_auto_draft();
    111                 $title   = 'RTC autosaved title';
    112                 $content = '<!-- wp:paragraph --><p>RTC autosaved content</p><!-- /wp:paragraph -->';
    113 
    114                 $response = $this->dispatch_autosave( $post_id, $title, $content );
    115 
    116                 $this->assertSame( 200, $response->get_status() );
    117                 $post = get_post( $post_id );
    118                 $this->assertSame( 'draft', $post->post_status );
    119                 $this->assertSame( $title, $post->post_title );
    120                 $this->assertSame( $content, $post->post_content );
    121         }
    122 
    123         /**
    124          * @ticket 65138
    125          */
    126         public function test_collaborator_auto_draft_autosave_promotes_parent_post_when_collaboration_is_enabled() {
    127                 update_option( 'wp_collaboration_enabled', 1 );
    128 
    129                 $post_id = $this->create_auto_draft();
    130                 $title   = 'RTC collaborator autosaved title';
    131                 $content = '<!-- wp:paragraph --><p>RTC collaborator autosaved content</p><!-- /wp:paragraph -->';
    132 
    133                 wp_set_current_user( self::$editor_id );
    134                 $response = $this->dispatch_autosave( $post_id, $title, $content );
    135 
    136                 $this->assertSame( 200, $response->get_status() );
    137                 $post = get_post( $post_id );
    138                 $this->assertSame( 'draft', $post->post_status );
    139                 $this->assertSame( $title, $post->post_title );
    140                 $this->assertSame( $content, $post->post_content );
    141         }
    142 
    143         /**
    144          * @ticket 65138
    145          */
    146         public function test_draft_autosave_creates_revision_when_collaboration_is_enabled() {
    147                 update_option( 'wp_collaboration_enabled', 1 );
    148 
    149                 $original_title   = 'Original RTC draft title';
    150                 $original_content = '<!-- wp:paragraph --><p>Original RTC draft content</p><!-- /wp:paragraph -->';
    151                 $post_id          = $this->create_draft( $original_title, $original_content );
    152                 $title            = 'RTC draft autosaved title';
    153                 $content          = '<!-- wp:paragraph --><p>RTC draft autosaved content</p><!-- /wp:paragraph -->';
    154 
    155                 $response = $this->dispatch_autosave( $post_id, $title, $content );
    156 
    157                 $this->assertSame( 200, $response->get_status() );
    158                 $post = get_post( $post_id );
    159                 $this->assertSame( 'draft', $post->post_status );
    160                 $this->assertSame( $original_title, $post->post_title );
    161                 $this->assertSame( $original_content, $post->post_content );
    162 
    163                 $autosave = wp_get_post_autosave( $post_id, self::$author_id );
    164                 $this->assertInstanceOf( WP_Post::class, $autosave );
    165                 $this->assertSame( $title, $autosave->post_title );
    166                 $this->assertSame( $content, $autosave->post_content );
    167         }
    168 }
  • trunk/tests/phpunit/tests/collaboration/wpSyncPostMetaStorage.php

    r62099 r62334  
    1 <?php
    2 /**
    3  * Tests for the WP_Sync_Post_Meta_Storage class.
    4  *
    5  * Covers the storage implementation contract: cache bypass, data integrity,
    6  * malformed data handling, and race-condition safety.
    7  *
    8  * @package WordPress
    9  * @subpackage Collaboration
    10  *
    11  * @group collaboration
    12  * @group cache
    13  */
    14 class Tests_Collaboration_WpSyncPostMetaStorage extends WP_UnitTestCase {
    15 
    16         protected static $editor_id;
    17         protected static $post_id;
    18 
    19         public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
    20                 self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) );
    21                 self::$post_id   = $factory->post->create( array( 'post_author' => self::$editor_id ) );
    22                 update_option( 'wp_collaboration_enabled', 1 );
    23         }
    24 
    25         public static function wpTearDownAfterClass() {
    26                 self::delete_user( self::$editor_id );
    27                 delete_option( 'wp_collaboration_enabled' );
    28                 wp_delete_post( self::$post_id, true );
    29         }
    30 
    31         public function set_up() {
    32                 parent::set_up();
    33                 update_option( 'wp_collaboration_enabled', 1 );
    34 
    35                 // Reset storage post ID cache to ensure clean state after transaction rollback.
    36                 $reflection = new ReflectionProperty( 'WP_Sync_Post_Meta_Storage', 'storage_post_ids' );
    37                 if ( PHP_VERSION_ID < 80100 ) {
    38                         $reflection->setAccessible( true );
    39                 }
    40                 $reflection->setValue( null, array() );
    41         }
    42 
    43         /**
    44          * Returns the room identifier for the test post.
    45          *
    46          * @return string Room identifier.
    47          */
    48         private function get_room(): string {
    49                 return 'postType/post:' . self::$post_id;
    50         }
    51 
    52         /**
    53          * Creates the storage post for the room and returns its ID.
    54          *
    55          * Adds a seed update to trigger storage post creation, then looks up
    56          * the resulting post ID.
    57          *
    58          * @param WP_Sync_Post_Meta_Storage $storage Storage instance.
    59          * @param string                    $room    Room identifier.
    60          * @return int Storage post ID.
    61          */
    62         private function create_storage_post( WP_Sync_Post_Meta_Storage $storage, string $room ): int {
    63                 $storage->add_update(
    64                         $room,
    65                         array(
    66                                 'type' => 'update',
    67                                 'data' => 'seed',
    68                         )
    69                 );
    70 
    71                 $posts = get_posts(
    72                         array(
    73                                 'post_type'      => 'wp_sync_storage',
    74                                 'posts_per_page' => 1,
    75                                 'post_status'    => 'publish',
    76                                 'name'           => md5( $room ),
    77                                 'fields'         => 'ids',
    78                         )
    79                 );
    80 
    81                 $storage_post_id = array_first( $posts );
    82                 $this->assertIsInt( $storage_post_id );
    83 
    84                 return $storage_post_id;
    85         }
    86 
    87         /**
    88          * Primes the post meta object cache for a given post and returns the cached value.
    89          *
    90          * @param int $post_id Post ID.
    91          * @return array Cached meta data.
    92          */
    93         private function prime_and_get_meta_cache( int $post_id ): array {
    94                 update_meta_cache( 'post', array( $post_id ) );
    95 
    96                 $cached = wp_cache_get( $post_id, 'post_meta' );
    97                 $this->assertNotFalse( $cached, 'Post meta cache should be primed.' );
    98 
    99                 return $cached;
    100         }
    101 
    102         /**
    103          * Adding a sync update must not invalidate the post meta cache for the storage
    104          * post.
    105          *
    106          * @ticket 64916
    107          */
    108         public function test_add_update_does_not_invalidate_post_meta_cache() {
    109                 $storage         = new WP_Sync_Post_Meta_Storage();
    110                 $room            = $this->get_room();
    111                 $storage_post_id = $this->create_storage_post( $storage, $room );
    112                 $cached_before   = $this->prime_and_get_meta_cache( $storage_post_id );
    113 
    114                 $storage->add_update(
    115                         $room,
    116                         array(
    117                                 'type' => 'update',
    118                                 'data' => 'new',
    119                         )
    120                 );
    121 
    122                 $cached_after = wp_cache_get( $storage_post_id, 'post_meta' );
    123                 $this->assertSame(
    124                         $cached_before,
    125                         $cached_after,
    126                         'add_update() must not invalidate the post meta cache.'
    127                 );
    128         }
    129 
    130         /**
    131          * Setting awareness state must not invalidate the post meta cache for the
    132          * storage post.
    133          *
    134          * @ticket 64916
    135          */
    136         public function test_set_awareness_state_insert_does_not_invalidate_post_meta_cache() {
    137                 $storage         = new WP_Sync_Post_Meta_Storage();
    138                 $room            = $this->get_room();
    139                 $storage_post_id = $this->create_storage_post( $storage, $room );
    140                 $cached_before   = $this->prime_and_get_meta_cache( $storage_post_id );
    141 
    142                 // First call triggers an INSERT (no existing awareness row).
    143                 $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Test' ) ) );
    144 
    145                 $cached_after = wp_cache_get( $storage_post_id, 'post_meta' );
    146                 $this->assertSame(
    147                         $cached_before,
    148                         $cached_after,
    149                         'set_awareness_state() INSERT path must not invalidate the post meta cache.'
    150                 );
    151         }
    152 
    153         /**
    154          * Updating awareness state must not invalidate the post meta cache for the
    155          * storage post.
    156          *
    157          * @ticket 64916
    158          */
    159         public function test_set_awareness_state_update_does_not_invalidate_post_meta_cache() {
    160                 $storage         = new WP_Sync_Post_Meta_Storage();
    161                 $room            = $this->get_room();
    162                 $storage_post_id = $this->create_storage_post( $storage, $room );
    163 
    164                 // Create initial awareness row (INSERT path).
    165                 $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Initial' ) ) );
    166 
    167                 // Prime cache after the insert.
    168                 $cached_before = $this->prime_and_get_meta_cache( $storage_post_id );
    169 
    170                 // Second call triggers an UPDATE (existing awareness row).
    171                 $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Updated' ) ) );
    172 
    173                 $cached_after = wp_cache_get( $storage_post_id, 'post_meta' );
    174                 $this->assertSame(
    175                         $cached_before,
    176                         $cached_after,
    177                         'set_awareness_state() UPDATE path must not invalidate the post meta cache.'
    178                 );
    179         }
    180 
    181         /**
    182          * Removing updates / compaction must not invalidate the post meta cache for
    183          * the storage post.
    184          *
    185          * @ticket 64916
    186          */
    187         public function test_remove_updates_before_cursor_does_not_invalidate_post_meta_cache() {
    188                 $storage         = new WP_Sync_Post_Meta_Storage();
    189                 $room            = $this->get_room();
    190                 $storage_post_id = $this->create_storage_post( $storage, $room );
    191 
    192                 // Get a cursor after the seed update.
    193                 $storage->get_updates_after_cursor( $room, 0 );
    194                 $cursor = $storage->get_cursor( $room );
    195 
    196                 $cached_before = $this->prime_and_get_meta_cache( $storage_post_id );
    197 
    198                 $storage->remove_updates_before_cursor( $room, $cursor );
    199 
    200                 $cached_after = wp_cache_get( $storage_post_id, 'post_meta' );
    201                 $this->assertSame(
    202                         $cached_before,
    203                         $cached_after,
    204                         'remove_updates_before_cursor() must not invalidate the post meta cache.'
    205                 );
    206         }
    207 
    208         /**
    209          * Adding a sync update must not update the posts last_changed value.
    210          *
    211          * @ticket 64696
    212          */
    213         public function test_add_update_does_not_update_posts_last_changed() {
    214                 $storage = new WP_Sync_Post_Meta_Storage();
    215                 $room    = $this->get_room();
    216                 $this->create_storage_post( $storage, $room );
    217 
    218                 $last_changed_before = wp_cache_get_last_changed( 'posts' );
    219 
    220                 $storage->add_update(
    221                         $room,
    222                         array(
    223                                 'type' => 'update',
    224                                 'data' => 'new',
    225                         )
    226                 );
    227 
    228                 $this->assertSame(
    229                         $last_changed_before,
    230                         wp_cache_get_last_changed( 'posts' ),
    231                         'add_update() must not update posts last_changed.'
    232                 );
    233         }
    234 
    235         /**
    236          * Setting awareness state must not update the posts last_changed value.
    237          *
    238          * @ticket 64696
    239          */
    240         public function test_set_awareness_state_does_not_update_posts_last_changed() {
    241                 $storage = new WP_Sync_Post_Meta_Storage();
    242                 $room    = $this->get_room();
    243                 $this->create_storage_post( $storage, $room );
    244 
    245                 $last_changed_before = wp_cache_get_last_changed( 'posts' );
    246 
    247                 $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Test' ) ) );
    248 
    249                 $this->assertSame(
    250                         $last_changed_before,
    251                         wp_cache_get_last_changed( 'posts' ),
    252                         'set_awareness_state() must not update posts last_changed.'
    253                 );
    254         }
    255 
    256         /**
    257          * Updating awareness state must not update the posts last_changed value.
    258          *
    259          * @ticket 64916
    260          */
    261         public function test_set_awareness_state_update_does_not_update_posts_last_changed() {
    262                 $storage = new WP_Sync_Post_Meta_Storage();
    263                 $room    = $this->get_room();
    264                 $this->create_storage_post( $storage, $room );
    265 
    266                 $last_changed_before = wp_cache_get_last_changed( 'posts' );
    267 
    268                 // Create initial awareness row (INSERT path).
    269                 $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Initial' ) ) );
    270 
    271                 $this->assertSame(
    272                         $last_changed_before,
    273                         wp_cache_get_last_changed( 'posts' ),
    274                         'set_awareness_state() must not update posts last_changed.'
    275                 );
    276 
    277                 // Second call triggers an UPDATE (existing awareness row).
    278                 $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Updated' ) ) );
    279 
    280                 $this->assertSame(
    281                         $last_changed_before,
    282                         wp_cache_get_last_changed( 'posts' ),
    283                         'set_awareness_state() must not update posts last_changed.'
    284                 );
    285         }
    286 
    287         /**
    288          * Removing sync updates / compaction must not update the posts last_changed
    289          * value.
    290          *
    291          * @ticket 64916
    292          */
    293         public function test_remove_updates_before_cursor_does_not_update_posts_last_changed() {
    294                 $storage = new WP_Sync_Post_Meta_Storage();
    295                 $room    = $this->get_room();
    296                 $this->create_storage_post( $storage, $room );
    297 
    298                 $storage->get_updates_after_cursor( $room, 0 );
    299                 $cursor = $storage->get_cursor( $room );
    300 
    301                 $last_changed_before = wp_cache_get_last_changed( 'posts' );
    302 
    303                 $storage->remove_updates_before_cursor( $room, $cursor );
    304 
    305                 $this->assertSame(
    306                         $last_changed_before,
    307                         wp_cache_get_last_changed( 'posts' ),
    308                         'remove_updates_before_cursor() must not update posts last_changed.'
    309                 );
    310         }
    311 
    312         /**
    313          * Getting awareness state must not prime the post meta cache for the storage
    314          * post.
    315          *
    316          * @ticket 64916
    317          */
    318         public function test_get_awareness_state_does_not_prime_post_meta_cache() {
    319                 $storage         = new WP_Sync_Post_Meta_Storage();
    320                 $room            = $this->get_room();
    321                 $storage_post_id = $this->create_storage_post( $storage, $room );
    322 
    323                 // Populate awareness so there is data to read.
    324                 $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Test' ) ) );
    325 
    326                 // Clear any existing cache.
    327                 wp_cache_delete( $storage_post_id, 'post_meta' );
    328                 $this->assertFalse(
    329                         wp_cache_get( $storage_post_id, 'post_meta' ),
    330                         'Post meta cache should be empty before read.'
    331                 );
    332 
    333                 $storage->get_awareness_state( $room );
    334 
    335                 $this->assertFalse(
    336                         wp_cache_get( $storage_post_id, 'post_meta' ),
    337                         'get_awareness_state() must not prime the post meta cache.'
    338                 );
    339         }
    340 
    341         /**
    342          * Getting sync updates must not prime the post meta cache for the storage
    343          * post.
    344          *
    345          * @ticket 64916
    346          */
    347         public function test_get_updates_after_cursor_does_not_prime_post_meta_cache() {
    348                 $storage         = new WP_Sync_Post_Meta_Storage();
    349                 $room            = $this->get_room();
    350                 $storage_post_id = $this->create_storage_post( $storage, $room );
    351 
    352                 // Clear any existing cache.
    353                 wp_cache_delete( $storage_post_id, 'post_meta' );
    354                 $this->assertFalse(
    355                         wp_cache_get( $storage_post_id, 'post_meta' ),
    356                         'Post meta cache should be empty before read.'
    357                 );
    358 
    359                 $storage->get_updates_after_cursor( $room, 0 );
    360 
    361                 $this->assertFalse(
    362                         wp_cache_get( $storage_post_id, 'post_meta' ),
    363                         'get_updates_after_cursor() must not prime the post meta cache.'
    364                 );
    365         }
    366 
    367         /*
    368          * Data integrity tests.
    369          */
    370 
    371         public function test_get_updates_after_cursor_drops_malformed_json() {
    372                 global $wpdb;
    373 
    374                 $storage         = new WP_Sync_Post_Meta_Storage();
    375                 $room            = $this->get_room();
    376                 $storage_post_id = $this->create_storage_post( $storage, $room );
    377 
    378                 // Advance cursor past the seed update from create_storage_post().
    379                 $storage->get_updates_after_cursor( $room, 0 );
    380                 $cursor = $storage->get_cursor( $room );
    381 
    382                 // Insert a valid update.
    383                 $valid_update = array(
    384                         'type' => 'update',
    385                         'data' => 'dGVzdA==',
    386                 );
    387                 $this->assertTrue( $storage->add_update( $room, $valid_update ) );
    388 
    389                 // Insert a malformed JSON row directly into the database.
    390                 $wpdb->insert(
    391                         $wpdb->postmeta,
    392                         array(
    393                                 'post_id'    => $storage_post_id,
    394                                 'meta_key'   => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY,
    395                                 'meta_value' => '{invalid json',
    396                         ),
    397                         array( '%d', '%s', '%s' )
    398                 );
    399 
    400                 // Insert another valid update after the malformed one.
    401                 $valid_update_2 = array(
    402                         'type' => 'sync_step1',
    403                         'data' => 'c3RlcDE=',
    404                 );
    405                 $this->assertTrue( $storage->add_update( $room, $valid_update_2 ) );
    406 
    407                 $updates = $storage->get_updates_after_cursor( $room, $cursor );
    408 
    409                 // The malformed row should be dropped; only the valid updates should appear.
    410                 $this->assertCount( 2, $updates );
    411                 $this->assertSame( $valid_update, $updates[0] );
    412                 $this->assertSame( $valid_update_2, $updates[1] );
    413         }
    414 
    415         public function test_duplicate_awareness_rows_coalesces_obn_latest_row() {
    416                 global $wpdb;
    417 
    418                 $storage         = new WP_Sync_Post_Meta_Storage();
    419                 $room            = $this->get_room();
    420                 $storage_post_id = $this->create_storage_post( $storage, $room );
    421 
    422                 // Simulate a race: insert two awareness rows directly.
    423                 $wpdb->insert(
    424                         $wpdb->postmeta,
    425                         array(
    426                                 'post_id'    => $storage_post_id,
    427                                 'meta_key'   => WP_Sync_Post_Meta_Storage::AWARENESS_META_KEY,
    428                                 'meta_value' => wp_json_encode( array( 1 => array( 'name' => 'Stale' ) ) ),
    429                         ),
    430                         array( '%d', '%s', '%s' )
    431                 );
    432 
    433                 $wpdb->insert(
    434                         $wpdb->postmeta,
    435                         array(
    436                                 'post_id'    => $storage_post_id,
    437                                 'meta_key'   => WP_Sync_Post_Meta_Storage::AWARENESS_META_KEY,
    438                                 'meta_value' => wp_json_encode( array( 1 => array( 'name' => 'Latest' ) ) ),
    439                         ),
    440                         array( '%d', '%s', '%s' )
    441                 );
    442 
    443                 // get_awareness_state and set_awareness_state should target the latest row.
    444                 $awareness = $storage->get_awareness_state( $room );
    445                 $this->assertSame( array( 'name' => 'Latest' ), $awareness[0] );
    446                 $storage->set_awareness_state( $room, array( 1 => array( 'name' => 'Current' ) ) );
    447                 $awareness = $storage->get_awareness_state( $room );
    448                 $this->assertSame( array( 'name' => 'Current' ), $awareness[0] );
    449         }
    450 
    451         /*
    452          * Race-condition tests.
    453          *
    454          * These use a $wpdb proxy to inject concurrent writes between internal
    455          * query steps, verifying that the cursor-bounded query window prevents
    456          * data loss.
    457          */
    458 
    459         public function test_cursor_does_not_skip_update_inserted_during_fetch_window() {
    460                 global $wpdb;
    461 
    462                 $storage         = new WP_Sync_Post_Meta_Storage();
    463                 $room            = $this->get_room();
    464                 $storage_post_id = $this->create_storage_post( $storage, $room );
    465 
    466                 $seed_update = array(
    467                         'client_id' => 1,
    468                         'type'      => 'update',
    469                         'data'      => 'c2VlZA==',
    470                 );
    471 
    472                 $this->assertTrue( $storage->add_update( $room, $seed_update ) );
    473 
    474                 $initial_updates = $storage->get_updates_after_cursor( $room, 0 );
    475                 $baseline_cursor = $storage->get_cursor( $room );
    476 
    477                 // The seed from create_storage_post() plus the one we just added.
    478                 $this->assertGreaterThan( 0, $baseline_cursor );
    479 
    480                 $injected_update = array(
    481                         'client_id' => 9999,
    482                         'type'      => 'update',
    483                         'data'      => base64_encode( 'injected-during-fetch' ),
    484                 );
    485 
    486                 $original_wpdb = $wpdb;
    487                 $proxy_wpdb    = new class( $original_wpdb, $storage_post_id, $injected_update ) {
    488                         private $wpdb;
    489                         private $storage_post_id;
    490                         private $injected_update;
    491                         public $postmeta;
    492                         public $did_inject = false;
    493 
    494                         public function __construct( $wpdb, int $storage_post_id, array $injected_update ) {
    495                                 $this->wpdb            = $wpdb;
    496                                 $this->storage_post_id = $storage_post_id;
    497                                 $this->injected_update = $injected_update;
    498                                 $this->postmeta        = $wpdb->postmeta;
    499                         }
    500 
    501                         // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Proxy forwards fully prepared core queries.
    502                         public function prepare( ...$args ) {
    503                                 return $this->wpdb->prepare( ...$args );
    504                         }
    505 
    506                         public function get_row( $query = null, $output = OBJECT, $y = 0 ) {
    507                                 $result = $this->wpdb->get_row( $query, $output, $y );
    508 
    509                                 $this->maybe_inject_after_sync_query( $query );
    510 
    511                                 return $result;
    512                         }
    513 
    514                         public function get_var( $query = null, $x = 0, $y = 0 ) {
    515                                 $result = $this->wpdb->get_var( $query, $x, $y );
    516 
    517                                 $this->maybe_inject_after_sync_query( $query );
    518 
    519                                 return $result;
    520                         }
    521 
    522                         public function get_results( $query = null, $output = OBJECT ) {
    523                                 return $this->wpdb->get_results( $query, $output );
    524                         }
    525                         // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
    526 
    527                         public function __call( $name, $arguments ) {
    528                                 return $this->wpdb->$name( ...$arguments );
    529                         }
    530 
    531                         public function __get( $name ) {
    532                                 return $this->wpdb->$name;
    533                         }
    534 
    535                         public function __set( $name, $value ) {
    536                                 $this->wpdb->$name = $value;
    537                         }
    538 
    539                         private function inject_update(): void {
    540                                 if ( $this->did_inject ) {
    541                                         return;
    542                                 }
    543 
    544                                 $this->did_inject = true;
    545 
    546                                 $this->wpdb->insert(
    547                                         $this->wpdb->postmeta,
    548                                         array(
    549                                                 'post_id'    => $this->storage_post_id,
    550                                                 'meta_key'   => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY,
    551                                                 'meta_value' => wp_json_encode( $this->injected_update ),
    552                                         ),
    553                                         array( '%d', '%s', '%s' )
    554                                 );
    555                         }
    556 
    557                         private function maybe_inject_after_sync_query( $query ): void {
    558                                 if ( $this->did_inject || ! is_string( $query ) ) {
    559                                         return;
    560                                 }
    561 
    562                                 $targets_postmeta = false !== strpos( $query, $this->postmeta );
    563                                 $targets_post_id  = 1 === preg_match( '/\bpost_id\s*=\s*' . (int) $this->storage_post_id . '\b/', $query );
    564                                 $targets_meta_key = 1 === preg_match(
    565                                         "/\bmeta_key\s*=\s*'" . preg_quote( WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, '/' ) . "'/",
    566                                         $query
    567                                 );
    568 
    569                                 if ( $targets_postmeta && $targets_post_id && $targets_meta_key ) {
    570                                         $this->inject_update();
    571                                 }
    572                         }
    573                 };
    574 
    575                 $wpdb = $proxy_wpdb;
    576                 try {
    577                         $race_updates = $storage->get_updates_after_cursor( $room, $baseline_cursor );
    578                         $race_cursor  = $storage->get_cursor( $room );
    579                 } finally {
    580                         $wpdb = $original_wpdb;
    581                 }
    582 
    583                 $this->assertTrue( $proxy_wpdb->did_inject, 'Expected race-window update injection to occur.' );
    584                 $this->assertEmpty( $race_updates );
    585                 $this->assertSame( $baseline_cursor, $race_cursor );
    586 
    587                 $follow_up_updates = $storage->get_updates_after_cursor( $room, $race_cursor );
    588                 $follow_up_cursor  = $storage->get_cursor( $room );
    589 
    590                 $this->assertCount( 1, $follow_up_updates );
    591                 $this->assertSame( $injected_update, $follow_up_updates[0] );
    592                 $this->assertGreaterThan( $race_cursor, $follow_up_cursor );
    593         }
    594 
    595         public function test_compaction_does_not_delete_update_inserted_during_delete() {
    596                 global $wpdb;
    597 
    598                 $storage         = new WP_Sync_Post_Meta_Storage();
    599                 $room            = $this->get_room();
    600                 $storage_post_id = $this->create_storage_post( $storage, $room );
    601 
    602                 // Seed three updates so there's something to compact.
    603                 for ( $i = 1; $i <= 3; $i++ ) {
    604                         $this->assertTrue(
    605                                 $storage->add_update(
    606                                         $room,
    607                                         array(
    608                                                 'client_id' => $i,
    609                                                 'type'      => 'update',
    610                                                 'data'      => base64_encode( "seed-$i" ),
    611                                         )
    612                                 )
    613                         );
    614                 }
    615 
    616                 // Capture the cursor after all seeds are in place.
    617                 $storage->get_updates_after_cursor( $room, 0 );
    618                 $compaction_cursor = $storage->get_cursor( $room );
    619                 $this->assertGreaterThan( 0, $compaction_cursor );
    620 
    621                 $concurrent_update = array(
    622                         'client_id' => 9999,
    623                         'type'      => 'update',
    624                         'data'      => base64_encode( 'arrived-during-compaction' ),
    625                 );
    626 
    627                 $original_wpdb = $wpdb;
    628                 $proxy_wpdb    = new class( $original_wpdb, $storage_post_id, $concurrent_update ) {
    629                         private $wpdb;
    630                         private $storage_post_id;
    631                         private $concurrent_update;
    632                         public $did_inject = false;
    633 
    634                         public function __construct( $wpdb, int $storage_post_id, array $concurrent_update ) {
    635                                 $this->wpdb              = $wpdb;
    636                                 $this->storage_post_id   = $storage_post_id;
    637                                 $this->concurrent_update = $concurrent_update;
    638                         }
    639 
    640                         // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Proxy forwards fully prepared core queries.
    641                         public function prepare( ...$args ) {
    642                                 return $this->wpdb->prepare( ...$args );
    643                         }
    644 
    645                         public function query( $query ) {
    646                                 $result = $this->wpdb->query( $query );
    647 
    648                                 // After the DELETE executes, inject a concurrent update via
    649                                 // raw SQL through the real $wpdb to avoid metadata cache
    650                                 // interactions while the proxy is active.
    651                                 if ( ! $this->did_inject
    652                                         && is_string( $query )
    653                                         && 0 === strpos( $query, "DELETE FROM {$this->wpdb->postmeta}" )
    654                                         && false !== strpos( $query, "post_id = {$this->storage_post_id}" )
    655                                 ) {
    656                                         $this->did_inject = true;
    657                                         $this->wpdb->insert(
    658                                                 $this->wpdb->postmeta,
    659                                                 array(
    660                                                         'post_id'    => $this->storage_post_id,
    661                                                         'meta_key'   => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY,
    662                                                         'meta_value' => wp_json_encode( $this->concurrent_update ),
    663                                                 ),
    664                                                 array( '%d', '%s', '%s' )
    665                                         );
    666                                 }
    667 
    668                                 return $result;
    669                         }
    670                         // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
    671 
    672                         public function __call( $name, $arguments ) {
    673                                 return $this->wpdb->$name( ...$arguments );
    674                         }
    675 
    676                         public function __get( $name ) {
    677                                 return $this->wpdb->$name;
    678                         }
    679 
    680                         public function __set( $name, $value ) {
    681                                 $this->wpdb->$name = $value;
    682                         }
    683                 };
    684 
    685                 // Run compaction through the proxy so the concurrent update
    686                 // is injected immediately after the DELETE executes.
    687                 $wpdb = $proxy_wpdb;
    688                 try {
    689                         $result = $storage->remove_updates_before_cursor( $room, $compaction_cursor );
    690                 } finally {
    691                         $wpdb = $original_wpdb;
    692                 }
    693 
    694                 $this->assertTrue( $result );
    695                 $this->assertTrue( $proxy_wpdb->did_inject, 'Expected concurrent update injection to occur.' );
    696 
    697                 // The concurrent update must survive the compaction delete.
    698                 $updates = $storage->get_updates_after_cursor( $room, 0 );
    699 
    700                 $update_data = wp_list_pluck( $updates, 'data' );
    701                 $this->assertContains(
    702                         $concurrent_update['data'],
    703                         $update_data,
    704                         'Concurrent update should survive compaction.'
    705                 );
    706         }
    707 }
  • trunk/tests/phpunit/tests/rest-api/rest-autosaves-controller.php

    r62058 r62334  
    571571
    572572        public function test_rest_autosave_draft_post_same_author() {
    573                 add_filter( 'pre_option_wp_collaboration_enabled', '__return_zero' ); // Zero as false doesn't work for pre-flight options.
    574 
    575573                wp_set_current_user( self::$editor_id );
    576574
     
    747745
    748746        public function test_update_item_draft_page_with_parent() {
    749                 add_filter( 'pre_option_wp_collaboration_enabled', '__return_zero' ); // Zero as false doesn't work for pre-flight options.
    750 
    751747                wp_set_current_user( self::$editor_id );
    752748                $request = new WP_REST_Request( 'POST', '/wp/v2/pages/' . self::$child_draft_page_id . '/autosaves' );
     
    925921                );
    926922        }
    927 
    928         /**
    929          * When real-time collaboration is enabled, autosaving a draft post by the
    930          * same author should create a revision instead of updating the post directly.
    931          */
    932         public function test_rest_autosave_draft_post_same_author_with_rtc() {
    933                 add_filter( 'pre_option_wp_collaboration_enabled', '__return_true' );
    934 
    935                 wp_set_current_user( self::$editor_id );
    936 
    937                 $post_data = array(
    938                         'post_content' => 'Test post content',
    939                         'post_title'   => 'Test post title',
    940                         'post_excerpt' => 'Test post excerpt',
    941                 );
    942                 $post_id   = wp_insert_post( $post_data );
    943 
    944                 $autosave_data = array(
    945                         'id'      => $post_id,
    946                         'content' => 'Updated post \ content',
    947                         'title'   => 'Updated post title',
    948                 );
    949 
    950                 $request = new WP_REST_Request( 'POST', '/wp/v2/posts/' . self::$post_id . '/autosaves' );
    951                 $request->add_header( 'Content-Type', 'application/json' );
    952                 $request->set_body( wp_json_encode( $autosave_data ) );
    953 
    954                 $response = rest_get_server()->dispatch( $request );
    955                 $new_data = $response->get_data();
    956                 $post     = get_post( $post_id );
    957 
    958                 // With RTC enabled, a revision is created instead of updating the post.
    959                 $this->assertNotSame( $post_id, $new_data['id'] );
    960                 $this->assertSame( $post_id, $new_data['parent'] );
    961 
    962                 // The autosave revision should have the updated content.
    963                 $this->assertSame( $autosave_data['content'], $new_data['content']['raw'] );
    964                 $this->assertSame( $autosave_data['title'], $new_data['title']['raw'] );
    965 
    966                 // The draft post should not be updated.
    967                 $this->assertSame( $post_data['post_content'], $post->post_content );
    968                 $this->assertSame( $post_data['post_title'], $post->post_title );
    969                 $this->assertSame( $post_data['post_excerpt'], $post->post_excerpt );
    970 
    971                 wp_delete_post( $post_id );
    972         }
    973 
    974         /**
    975          * When real-time collaboration is enabled, autosaving a draft page with
    976          * a parent should create a revision instead of updating the page directly.
    977          */
    978         public function test_update_item_draft_page_with_parent_with_rtc() {
    979                 add_filter( 'pre_option_wp_collaboration_enabled', '__return_true' );
    980 
    981                 wp_set_current_user( self::$editor_id );
    982                 $request = new WP_REST_Request( 'POST', '/wp/v2/pages/' . self::$child_draft_page_id . '/autosaves' );
    983                 $request->add_header( 'Content-Type', 'application/x-www-form-urlencoded' );
    984 
    985                 $params = $this->set_post_data(
    986                         array(
    987                                 'id'     => self::$child_draft_page_id,
    988                                 'author' => self::$editor_id,
    989                         )
    990                 );
    991 
    992                 $request->set_body_params( $params );
    993                 $response = rest_get_server()->dispatch( $request );
    994                 $data     = $response->get_data();
    995 
    996                 // With RTC enabled, a revision is created instead of updating the page.
    997                 $this->assertNotSame( self::$child_draft_page_id, $data['id'] );
    998                 $this->assertSame( self::$child_draft_page_id, $data['parent'] );
    999         }
    1000923}
  • trunk/tests/phpunit/tests/rest-api/rest-settings-controller.php

    r62210 r62334  
    120120                        'default_comment_status',
    121121                        'site_icon', // Registered in wp-includes/blocks/site-logo.php
    122                         'wp_collaboration_enabled',
    123122                );
    124123
  • trunk/tests/phpunit/tests/rest-api/rest-sync-server.php

    r62298 r62334  
    1 <?php
    2 /**
    3  * Tests for the WP_HTTP_Polling_Sync_Server REST endpoint.
    4  *
    5  * @package WordPress
    6  * @subpackage REST API
    7  *
    8  * @group restapi
    9  */
    10 class WP_Test_REST_Sync_Server extends WP_Test_REST_Controller_Testcase {
    11 
    12         protected static int $editor_id;
    13         protected static int $subscriber_id;
    14         protected static int $post_id;
    15         protected static int $category_id;
    16         protected static int $tag_id;
    17         protected static int $comment_id;
    18 
    19         public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
    20                 self::$editor_id     = $factory->user->create( array( 'role' => 'editor' ) );
    21                 self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) );
    22                 self::$post_id       = $factory->post->create( array( 'post_author' => self::$editor_id ) );
    23                 self::$category_id   = $factory->category->create();
    24                 self::$tag_id        = $factory->tag->create();
    25                 self::$comment_id    = $factory->comment->create( array( 'comment_post_ID' => self::$post_id ) );
    26 
    27                 // Enable option in setUpBeforeClass to ensure REST routes are registered.
    28                 update_option( 'wp_collaboration_enabled', 1 );
    29         }
    30 
    31         public static function wpTearDownAfterClass() {
    32                 self::delete_user( self::$editor_id );
    33                 self::delete_user( self::$subscriber_id );
    34                 delete_option( 'wp_collaboration_enabled' );
    35                 wp_delete_post( self::$post_id, true );
    36                 wp_delete_term( self::$category_id, 'category' );
    37                 wp_delete_term( self::$tag_id, 'post_tag' );
    38                 wp_delete_comment( self::$comment_id, true );
    39         }
    40 
    41         public function set_up() {
    42                 parent::set_up();
    43 
    44                 // Enable option for tests.
    45                 update_option( 'wp_collaboration_enabled', 1 );
    46 
    47                 // Reset storage post ID cache to ensure clean state after transaction rollback.
    48                 $reflection = new ReflectionProperty( 'WP_Sync_Post_Meta_Storage', 'storage_post_ids' );
    49                 if ( PHP_VERSION_ID < 80100 ) {
    50                         $reflection->setAccessible( true );
    51                 }
    52                 $reflection->setValue( null, array() );
    53         }
    54 
    55         /**
    56          * Builds a room request array for the sync endpoint.
    57          *
    58          * @param string $room      Room identifier.
    59          * @param int    $client_id Client ID.
    60          * @param int    $cursor    Cursor value for the 'after' parameter.
    61          * @param array  $awareness Awareness state.
    62          * @param array  $updates   Array of updates.
    63          * @return array Room request data.
    64          */
    65         private function build_room( $room, $client_id = 1, $cursor = 0, $awareness = array(), $updates = array() ) {
    66                 if ( empty( $awareness ) ) {
    67                         $awareness = array( 'user' => 'test' );
    68                 }
    69 
    70                 return array(
    71                         'after'     => $cursor,
    72                         'awareness' => $awareness,
    73                         'client_id' => $client_id,
    74                         'room'      => $room,
    75                         'updates'   => $updates,
    76                 );
    77         }
    78 
    79         /**
    80          * Dispatches a sync request with the given rooms.
    81          *
    82          * @param array $rooms Array of room request data.
    83          * @return WP_REST_Response Response object.
    84          */
    85         private function dispatch_sync( $rooms ) {
    86                 $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' );
    87                 $request->set_body_params( array( 'rooms' => $rooms ) );
    88                 return rest_get_server()->dispatch( $request );
    89         }
    90 
    91         /**
    92          * Returns the default room identifier for the test post.
    93          *
    94          * @return string Room identifier.
    95          */
    96         private function get_post_room() {
    97                 return 'postType/post:' . self::$post_id;
    98         }
    99 
    100         /*
    101          * Required abstract method implementations.
    102          *
    103          * The sync endpoint is a single POST endpoint, not a standard CRUD controller.
    104          * Methods that don't apply are stubbed with @doesNotPerformAssertions.
    105          */
    106 
    107         public function test_register_routes() {
    108                 $routes = rest_get_server()->get_routes();
    109                 $this->assertArrayHasKey( '/wp-sync/v1/updates', $routes );
    110         }
    111 
    112         /**
    113          * Verifies the sync route is registered when relying on the option's default
    114          * value (option not stored in the database).
    115          *
    116          * This covers the upgrade scenario where a site has never explicitly saved
    117          * the collaboration setting.
    118          *
    119          * @ticket 64814
    120          */
    121         public function test_register_routes_with_default_option() {
    122                 global $wp_rest_server;
    123 
    124                 // Ensure the option is not in the database.
    125                 delete_option( 'wp_collaboration_enabled' );
    126 
    127                 // Reset the REST server so routes are re-registered from scratch.
    128                 $wp_rest_server = null;
    129 
    130                 $routes = rest_get_server()->get_routes();
    131                 $this->assertArrayNotHasKey( '/wp-sync/v1/updates', $routes );
    132         }
    133 
    134         /**
    135          * @doesNotPerformAssertions
    136          */
    137         public function test_context_param() {
    138                 // Not applicable for sync endpoint.
    139         }
    140 
    141         /**
    142          * @doesNotPerformAssertions
    143          */
    144         public function test_get_items() {
    145                 // Not applicable for sync endpoint.
    146         }
    147 
    148         /**
    149          * @doesNotPerformAssertions
    150          */
    151         public function test_get_item() {
    152                 // Not applicable for sync endpoint.
    153         }
    154 
    155         public function test_create_item() {
    156                 wp_set_current_user( self::$editor_id );
    157 
    158                 $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) );
    159 
    160                 $this->assertSame( 200, $response->get_status() );
    161         }
    162 
    163         /**
    164          * @doesNotPerformAssertions
    165          */
    166         public function test_update_item() {
    167                 // Not applicable for sync endpoint.
    168         }
    169 
    170         /**
    171          * @doesNotPerformAssertions
    172          */
    173         public function test_delete_item() {
    174                 // Not applicable for sync endpoint.
    175         }
    176 
    177         /**
    178          * @doesNotPerformAssertions
    179          */
    180         public function test_prepare_item() {
    181                 // Not applicable for sync endpoint.
    182         }
    183 
    184         /**
    185          * @doesNotPerformAssertions
    186          */
    187         public function test_get_item_schema() {
    188                 // Not applicable for sync endpoint.
    189         }
    190 
    191         /*
    192          * Permission tests.
    193          */
    194 
    195         public function test_sync_requires_authentication() {
    196                 wp_set_current_user( 0 );
    197 
    198                 $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) );
    199 
    200                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 );
    201         }
    202 
    203         public function test_sync_post_requires_edit_capability() {
    204                 wp_set_current_user( self::$subscriber_id );
    205 
    206                 $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) );
    207 
    208                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    209         }
    210 
    211         public function test_sync_post_allowed_with_edit_capability() {
    212                 wp_set_current_user( self::$editor_id );
    213 
    214                 $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) );
    215 
    216                 $this->assertSame( 200, $response->get_status() );
    217         }
    218 
    219         public function test_sync_post_type_collection_requires_edit_posts_capability() {
    220                 wp_set_current_user( self::$subscriber_id );
    221 
    222                 $response = $this->dispatch_sync( array( $this->build_room( 'postType/post' ) ) );
    223 
    224                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    225         }
    226 
    227         public function test_sync_post_type_collection_allowed_with_edit_posts_capability() {
    228                 wp_set_current_user( self::$editor_id );
    229 
    230                 $response = $this->dispatch_sync( array( $this->build_room( 'postType/post' ) ) );
    231 
    232                 $this->assertSame( 200, $response->get_status() );
    233         }
    234 
    235         public function test_sync_root_collection_allowed() {
    236                 wp_set_current_user( self::$editor_id );
    237 
    238                 $response = $this->dispatch_sync( array( $this->build_room( 'root/site' ) ) );
    239 
    240                 $this->assertSame( 200, $response->get_status() );
    241         }
    242 
    243         public function test_sync_taxonomy_collection_allowed() {
    244                 wp_set_current_user( self::$editor_id );
    245 
    246                 $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category' ) ) );
    247 
    248                 $this->assertSame( 200, $response->get_status() );
    249         }
    250 
    251         public function test_sync_unknown_collection_kind_rejected() {
    252                 wp_set_current_user( self::$editor_id );
    253 
    254                 $response = $this->dispatch_sync( array( $this->build_room( 'unknown/entity' ) ) );
    255 
    256                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    257         }
    258 
    259         public function test_sync_non_posttype_entity_with_object_id_rejected() {
    260                 wp_set_current_user( self::$editor_id );
    261 
    262                 $response = $this->dispatch_sync( array( $this->build_room( 'root/site:123' ) ) );
    263 
    264                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    265         }
    266 
    267         public function test_sync_nonexistent_post_rejected() {
    268                 wp_set_current_user( self::$editor_id );
    269 
    270                 $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:999999' ) ) );
    271 
    272                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    273         }
    274 
    275         public function test_sync_permission_checked_per_room() {
    276                 wp_set_current_user( self::$editor_id );
    277 
    278                 // First room is allowed, second room is forbidden.
    279                 $response = $this->dispatch_sync(
    280                         array(
    281                                 $this->build_room( $this->get_post_room() ),
    282                                 $this->build_room( 'unknown/entity' ),
    283                         )
    284                 );
    285 
    286                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    287         }
    288 
    289         /**
    290          * @ticket 64890
    291          */
    292         public function test_sync_malformed_object_id_rejected() {
    293                 wp_set_current_user( self::$editor_id );
    294 
    295                 $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:1abc' ) ) );
    296 
    297                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    298         }
    299 
    300         /**
    301          * @ticket 64890
    302          */
    303         public function test_sync_zero_object_id_rejected(): void {
    304                 wp_set_current_user( self::$editor_id );
    305 
    306                 $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:0' ) ) );
    307 
    308                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    309         }
    310 
    311         /**
    312          * @ticket 64890
    313          */
    314         public function test_sync_post_type_mismatch_rejected(): void {
    315                 wp_set_current_user( self::$editor_id );
    316 
    317                 // The test post is of type 'post', not 'page'.
    318                 $response = $this->dispatch_sync( array( $this->build_room( 'postType/page:' . self::$post_id ) ) );
    319 
    320                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    321         }
    322 
    323         /**
    324          * @ticket 64890
    325          */
    326         public function test_sync_taxonomy_term_allowed(): void {
    327                 wp_set_current_user( self::$editor_id );
    328 
    329                 $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:' . self::$category_id ) ) );
    330 
    331                 $this->assertSame( 200, $response->get_status() );
    332         }
    333 
    334         /**
    335          * @ticket 64890
    336          */
    337         public function test_sync_nonexistent_taxonomy_term_rejected(): void {
    338                 wp_set_current_user( self::$editor_id );
    339 
    340                 $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:999999' ) ) );
    341 
    342                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    343         }
    344 
    345         /**
    346          * @ticket 64890
    347          */
    348         public function test_sync_taxonomy_term_wrong_taxonomy_rejected(): void {
    349                 wp_set_current_user( self::$editor_id );
    350 
    351                 // The tag term exists in 'post_tag', not 'category'.
    352                 $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:' . self::$tag_id ) ) );
    353 
    354                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    355         }
    356 
    357         /**
    358          * @ticket 64890
    359          */
    360         public function test_sync_comment_allowed(): void {
    361                 wp_set_current_user( self::$editor_id );
    362 
    363                 $response = $this->dispatch_sync( array( $this->build_room( 'root/comment:' . self::$comment_id ) ) );
    364 
    365                 $this->assertSame( 200, $response->get_status() );
    366         }
    367 
    368         /**
    369          * @ticket 64890
    370          */
    371         public function test_sync_nonexistent_comment_rejected(): void {
    372                 wp_set_current_user( self::$editor_id );
    373 
    374                 $response = $this->dispatch_sync( array( $this->build_room( 'root/comment:999999' ) ) );
    375 
    376                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    377         }
    378 
    379         /**
    380          * @ticket 64890
    381          */
    382         public function test_sync_nonexistent_post_type_collection_rejected(): void {
    383                 wp_set_current_user( self::$editor_id );
    384 
    385                 $response = $this->dispatch_sync( array( $this->build_room( 'postType/nonexistent_type' ) ) );
    386 
    387                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    388         }
    389 
    390         /*
    391          * Validation tests.
    392          */
    393 
    394         public function test_sync_invalid_room_format_rejected() {
    395                 wp_set_current_user( self::$editor_id );
    396 
    397                 $response = $this->dispatch_sync(
    398                         array(
    399                                 $this->build_room( 'invalid-room-format' ),
    400                         )
    401                 );
    402 
    403                 $this->assertSame( 400, $response->get_status() );
    404         }
    405 
    406         /**
    407          * Verifies that schema type validation rejects a non-string value for the
    408          * update 'data' field, confirming that per-arg schema validation still runs
    409          * with a route-level validate_callback registered.
    410          *
    411          * @ticket 64890
    412          */
    413         public function test_sync_rejects_non_string_update_data(): void {
    414                 wp_set_current_user( self::$editor_id );
    415 
    416                 $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' );
    417                 $request->set_body_params(
    418                         array(
    419                                 'rooms' => array(
    420                                         array(
    421                                                 'after'     => 0,
    422                                                 'awareness' => array( 'user' => 'test' ),
    423                                                 'client_id' => 1,
    424                                                 'room'      => $this->get_post_room(),
    425                                                 'updates'   => array(
    426                                                         array(
    427                                                                 'data' => 12345,
    428                                                                 'type' => 'update',
    429                                                         ),
    430                                                 ),
    431                                         ),
    432                                 ),
    433                         )
    434                 );
    435 
    436                 $response = rest_get_server()->dispatch( $request );
    437                 $this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
    438         }
    439 
    440         /**
    441          * Verifies that schema enum validation rejects an invalid update type,
    442          * confirming that per-arg schema validation still runs with a route-level
    443          * validate_callback registered.
    444          *
    445          * @ticket 64890
    446          */
    447         public function test_sync_rejects_invalid_update_type_enum(): void {
    448                 wp_set_current_user( self::$editor_id );
    449 
    450                 $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' );
    451                 $request->set_body_params(
    452                         array(
    453                                 'rooms' => array(
    454                                         array(
    455                                                 'after'     => 0,
    456                                                 'awareness' => array( 'user' => 'test' ),
    457                                                 'client_id' => 1,
    458                                                 'room'      => $this->get_post_room(),
    459                                                 'updates'   => array(
    460                                                         array(
    461                                                                 'data' => 'dGVzdA==',
    462                                                                 'type' => 'invalid_type',
    463                                                         ),
    464                                                 ),
    465                                         ),
    466                                 ),
    467                         )
    468                 );
    469 
    470                 $response = rest_get_server()->dispatch( $request );
    471                 $this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
    472         }
    473 
    474         /**
    475          * Verifies that schema required-field validation rejects a room missing
    476          * the 'client_id' field, confirming that per-arg schema validation still
    477          * runs with a route-level validate_callback registered.
    478          *
    479          * @ticket 64890
    480          */
    481         public function test_sync_rejects_missing_required_room_field(): void {
    482                 wp_set_current_user( self::$editor_id );
    483 
    484                 $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' );
    485                 $request->set_body_params(
    486                         array(
    487                                 'rooms' => array(
    488                                         array(
    489                                                 'after'     => 0,
    490                                                 'awareness' => array( 'user' => 'test' ),
    491                                                 // 'client_id' deliberately omitted.
    492                                                 'room'      => $this->get_post_room(),
    493                                                 'updates'   => array(),
    494                                         ),
    495                                 ),
    496                         )
    497                 );
    498 
    499                 $response = rest_get_server()->dispatch( $request );
    500                 $this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
    501         }
    502 
    503         /**
    504          * Verifies that the maxItems constraint rejects a request with more rooms
    505          * than MAX_ROOMS_PER_REQUEST.
    506          *
    507          * @ticket 64890
    508          */
    509         public function test_sync_rejects_rooms_exceeding_max_items(): void {
    510                 wp_set_current_user( self::$editor_id );
    511 
    512                 $rooms = array();
    513                 for ( $i = 0; $i < WP_HTTP_Polling_Sync_Server::MAX_ROOMS_PER_REQUEST + 1; $i++ ) {
    514                         $rooms[] = $this->build_room( 'root/site', $i + 1 );
    515                 }
    516 
    517                 $response = $this->dispatch_sync( $rooms );
    518                 $this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
    519         }
    520 
    521         /**
    522          * Verifies that the maxLength constraint rejects update data exceeding
    523          * MAX_UPDATE_DATA_SIZE.
    524          *
    525          * @ticket 64890
    526          */
    527         public function test_sync_rejects_update_data_exceeding_max_length(): void {
    528                 wp_set_current_user( self::$editor_id );
    529 
    530                 $oversized_data = str_repeat( 'a', WP_HTTP_Polling_Sync_Server::MAX_UPDATE_DATA_SIZE + 1 );
    531 
    532                 $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' );
    533                 $request->set_body_params(
    534                         array(
    535                                 'rooms' => array(
    536                                         array(
    537                                                 'after'     => 0,
    538                                                 'awareness' => array( 'user' => 'test' ),
    539                                                 'client_id' => 1,
    540                                                 'room'      => $this->get_post_room(),
    541                                                 'updates'   => array(
    542                                                         array(
    543                                                                 'data' => $oversized_data,
    544                                                                 'type' => 'update',
    545                                                         ),
    546                                                 ),
    547                                         ),
    548                                 ),
    549                         )
    550                 );
    551 
    552                 $response = rest_get_server()->dispatch( $request );
    553                 $this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
    554         }
    555 
    556         /**
    557          * Verifies that the route-level validate_callback rejects a request body
    558          * exceeding MAX_BODY_SIZE.
    559          *
    560          * @ticket 64890
    561          */
    562         public function test_sync_rejects_oversized_request_body(): void {
    563                 wp_set_current_user( self::$editor_id );
    564 
    565                 $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' );
    566 
    567                 // Set valid parsed params so per-arg schema validation passes first.
    568                 $request->set_body_params(
    569                         array(
    570                                 'rooms' => array(
    571                                         $this->build_room( $this->get_post_room() ),
    572                                 ),
    573                         )
    574                 );
    575 
    576                 // Set an oversized raw body to trigger the route-level validate_callback.
    577                 $request->set_body( str_repeat( 'x', WP_HTTP_Polling_Sync_Server::MAX_BODY_SIZE + 1 ) );
    578 
    579                 $response = rest_get_server()->dispatch( $request );
    580                 $this->assertErrorResponse( 'rest_sync_body_too_large', $response, 413 );
    581         }
    582 
    583         /*
    584          * Response format tests.
    585          */
    586 
    587         public function test_sync_response_structure() {
    588                 wp_set_current_user( self::$editor_id );
    589 
    590                 $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) );
    591 
    592                 $this->assertSame( 200, $response->get_status() );
    593 
    594                 $data = $response->get_data();
    595                 $this->assertArrayHasKey( 'rooms', $data );
    596                 $this->assertCount( 1, $data['rooms'] );
    597 
    598                 $room_data = $data['rooms'][0];
    599                 $this->assertArrayHasKey( 'room', $room_data );
    600                 $this->assertArrayHasKey( 'awareness', $room_data );
    601                 $this->assertArrayHasKey( 'updates', $room_data );
    602                 $this->assertArrayHasKey( 'end_cursor', $room_data );
    603                 $this->assertArrayHasKey( 'total_updates', $room_data );
    604                 $this->assertArrayHasKey( 'should_compact', $room_data );
    605         }
    606 
    607         public function test_sync_response_room_matches_request() {
    608                 wp_set_current_user( self::$editor_id );
    609 
    610                 $room     = $this->get_post_room();
    611                 $response = $this->dispatch_sync( array( $this->build_room( $room ) ) );
    612 
    613                 $data = $response->get_data();
    614                 $this->assertSame( $room, $data['rooms'][0]['room'] );
    615         }
    616 
    617         public function test_sync_end_cursor_is_positive_integer() {
    618                 wp_set_current_user( self::$editor_id );
    619 
    620                 $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) );
    621 
    622                 $data = $response->get_data();
    623                 $this->assertIsInt( $data['rooms'][0]['end_cursor'] );
    624                 $this->assertGreaterThanOrEqual( 0, $data['rooms'][0]['end_cursor'] );
    625         }
    626 
    627         public function test_sync_empty_updates_returns_zero_total() {
    628                 wp_set_current_user( self::$editor_id );
    629 
    630                 $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) );
    631 
    632                 $data = $response->get_data();
    633                 $this->assertSame( 0, $data['rooms'][0]['total_updates'] );
    634                 $this->assertEmpty( $data['rooms'][0]['updates'] );
    635         }
    636 
    637         /*
    638          * Update tests.
    639          */
    640 
    641         public function test_sync_update_delivered_to_other_client() {
    642                 wp_set_current_user( self::$editor_id );
    643 
    644                 $room   = $this->get_post_room();
    645                 $update = array(
    646                         'type' => 'update',
    647                         'data' => 'dGVzdCBkYXRh',
    648                 );
    649 
    650                 // Client 1 sends an update.
    651                 $this->dispatch_sync(
    652                         array(
    653                                 $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ),
    654                         )
    655                 );
    656 
    657                 // Client 2 requests updates from the beginning.
    658                 $response = $this->dispatch_sync(
    659                         array(
    660                                 $this->build_room( $room, 2, 0 ),
    661                         )
    662                 );
    663 
    664                 $data    = $response->get_data();
    665                 $updates = $data['rooms'][0]['updates'];
    666 
    667                 $this->assertNotEmpty( $updates );
    668 
    669                 $types = wp_list_pluck( $updates, 'type' );
    670                 $this->assertContains( 'update', $types );
    671         }
    672 
    673         public function test_sync_own_updates_not_returned() {
    674                 wp_set_current_user( self::$editor_id );
    675 
    676                 $room   = $this->get_post_room();
    677                 $update = array(
    678                         'type' => 'update',
    679                         'data' => 'b3duIGRhdGE=',
    680                 );
    681 
    682                 // Client 1 sends an update.
    683                 $response = $this->dispatch_sync(
    684                         array(
    685                                 $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ),
    686                         )
    687                 );
    688 
    689                 $data    = $response->get_data();
    690                 $updates = $data['rooms'][0]['updates'];
    691 
    692                 // Client 1 should not see its own non-compaction update.
    693                 $this->assertEmpty( $updates );
    694         }
    695 
    696         public function test_sync_step1_update_stored_and_returned() {
    697                 wp_set_current_user( self::$editor_id );
    698 
    699                 $room   = $this->get_post_room();
    700                 $update = array(
    701                         'type' => 'sync_step1',
    702                         'data' => 'c3RlcDE=',
    703                 );
    704 
    705                 // Client 1 sends sync_step1.
    706                 $this->dispatch_sync(
    707                         array(
    708                                 $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ),
    709                         )
    710                 );
    711 
    712                 // Client 2 should see the sync_step1 update.
    713                 $response = $this->dispatch_sync(
    714                         array(
    715                                 $this->build_room( $room, 2, 0 ),
    716                         )
    717                 );
    718 
    719                 $data  = $response->get_data();
    720                 $types = wp_list_pluck( $data['rooms'][0]['updates'], 'type' );
    721                 $this->assertContains( 'sync_step1', $types );
    722         }
    723 
    724         public function test_sync_step2_update_stored_and_returned() {
    725                 wp_set_current_user( self::$editor_id );
    726 
    727                 $room   = $this->get_post_room();
    728                 $update = array(
    729                         'type' => 'sync_step2',
    730                         'data' => 'c3RlcDI=',
    731                 );
    732 
    733                 // Client 1 sends sync_step2.
    734                 $this->dispatch_sync(
    735                         array(
    736                                 $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ),
    737                         )
    738                 );
    739 
    740                 // Client 2 should see the sync_step2 update.
    741                 $response = $this->dispatch_sync(
    742                         array(
    743                                 $this->build_room( $room, 2, 0 ),
    744                         )
    745                 );
    746 
    747                 $data  = $response->get_data();
    748                 $types = wp_list_pluck( $data['rooms'][0]['updates'], 'type' );
    749                 $this->assertContains( 'sync_step2', $types );
    750         }
    751 
    752         public function test_sync_multiple_updates_in_single_request() {
    753                 wp_set_current_user( self::$editor_id );
    754 
    755                 $room    = $this->get_post_room();
    756                 $updates = array(
    757                         array(
    758                                 'type' => 'sync_step1',
    759                                 'data' => 'c3RlcDE=',
    760                         ),
    761                         array(
    762                                 'type' => 'update',
    763                                 'data' => 'dXBkYXRl',
    764                         ),
    765                 );
    766 
    767                 // Client 1 sends multiple updates.
    768                 $this->dispatch_sync(
    769                         array(
    770                                 $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), $updates ),
    771                         )
    772                 );
    773 
    774                 // Client 2 should see both updates.
    775                 $response = $this->dispatch_sync(
    776                         array(
    777                                 $this->build_room( $room, 2, 0 ),
    778                         )
    779                 );
    780 
    781                 $data         = $response->get_data();
    782                 $room_updates = $data['rooms'][0]['updates'];
    783 
    784                 $this->assertCount( 2, $room_updates );
    785                 $this->assertSame( 2, $data['rooms'][0]['total_updates'] );
    786         }
    787 
    788         public function test_sync_update_data_preserved() {
    789                 wp_set_current_user( self::$editor_id );
    790 
    791                 $room   = $this->get_post_room();
    792                 $update = array(
    793                         'type' => 'update',
    794                         'data' => 'cHJlc2VydmVkIGRhdGE=',
    795                 );
    796 
    797                 // Client 1 sends an update.
    798                 $this->dispatch_sync(
    799                         array(
    800                                 $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ),
    801                         )
    802                 );
    803 
    804                 // Client 2 should receive the exact same data.
    805                 $response = $this->dispatch_sync(
    806                         array(
    807                                 $this->build_room( $room, 2, 0 ),
    808                         )
    809                 );
    810 
    811                 $data         = $response->get_data();
    812                 $room_updates = $data['rooms'][0]['updates'];
    813 
    814                 $this->assertSame( 'cHJlc2VydmVkIGRhdGE=', $room_updates[0]['data'] );
    815                 $this->assertSame( 'update', $room_updates[0]['type'] );
    816         }
    817 
    818         public function test_sync_total_updates_increments() {
    819                 wp_set_current_user( self::$editor_id );
    820 
    821                 $room   = $this->get_post_room();
    822                 $update = array(
    823                         'type' => 'update',
    824                         'data' => 'dGVzdA==',
    825                 );
    826 
    827                 // Send three updates from different clients.
    828                 $this->dispatch_sync(
    829                         array(
    830                                 $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ),
    831                         )
    832                 );
    833                 $this->dispatch_sync(
    834                         array(
    835                                 $this->build_room( $room, 2, 0, array( 'user' => 'c2' ), array( $update ) ),
    836                         )
    837                 );
    838                 $this->dispatch_sync(
    839                         array(
    840                                 $this->build_room( $room, 3, 0, array( 'user' => 'c3' ), array( $update ) ),
    841                         )
    842                 );
    843 
    844                 // Any client should see total_updates = 3.
    845                 $response = $this->dispatch_sync(
    846                         array(
    847                                 $this->build_room( $room, 4, 0 ),
    848                         )
    849                 );
    850 
    851                 $data = $response->get_data();
    852                 $this->assertSame( 3, $data['rooms'][0]['total_updates'] );
    853         }
    854 
    855         /*
    856          * Compaction tests.
    857          */
    858 
    859         public function test_sync_should_compact_is_false_below_threshold() {
    860                 wp_set_current_user( self::$editor_id );
    861 
    862                 $room   = $this->get_post_room();
    863                 $update = array(
    864                         'type' => 'update',
    865                         'data' => 'dGVzdA==',
    866                 );
    867 
    868                 // Client 1 sends a single update.
    869                 $response = $this->dispatch_sync(
    870                         array(
    871                                 $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ),
    872                         )
    873                 );
    874 
    875                 $data = $response->get_data();
    876                 $this->assertFalse( $data['rooms'][0]['should_compact'] );
    877         }
    878 
    879         public function test_sync_should_compact_is_true_above_threshold_for_compactor() {
    880                 wp_set_current_user( self::$editor_id );
    881 
    882                 $room    = $this->get_post_room();
    883                 $updates = array();
    884                 for ( $i = 0; $i < 51; $i++ ) {
    885                         $updates[] = array(
    886                                 'type' => 'update',
    887                                 'data' => base64_encode( "update-$i" ),
    888                         );
    889                 }
    890 
    891                 // Client 1 sends enough updates to exceed the compaction threshold.
    892                 $this->dispatch_sync(
    893                         array(
    894                                 $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $updates ),
    895                         )
    896                 );
    897 
    898                 // Client 1 polls again. It is the lowest (only) client, so it is the compactor.
    899                 $response = $this->dispatch_sync(
    900                         array(
    901                                 $this->build_room( $room, 1, 0, array( 'user' => 'c1' ) ),
    902                         )
    903                 );
    904 
    905                 $data = $response->get_data();
    906                 $this->assertTrue( $data['rooms'][0]['should_compact'] );
    907         }
    908 
    909         public function test_sync_should_compact_is_false_for_non_compactor() {
    910                 wp_set_current_user( self::$editor_id );
    911 
    912                 $room    = $this->get_post_room();
    913                 $updates = array();
    914                 for ( $i = 0; $i < 51; $i++ ) {
    915                         $updates[] = array(
    916                                 'type' => 'update',
    917                                 'data' => base64_encode( "update-$i" ),
    918                         );
    919                 }
    920 
    921                 // Client 1 sends enough updates to exceed the compaction threshold.
    922                 $this->dispatch_sync(
    923                         array(
    924                                 $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $updates ),
    925                         )
    926                 );
    927 
    928                 // Client 2 (higher ID than client 1) should not be the compactor.
    929                 $response = $this->dispatch_sync(
    930                         array(
    931                                 $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ),
    932                         )
    933                 );
    934 
    935                 $data = $response->get_data();
    936                 $this->assertFalse( $data['rooms'][0]['should_compact'] );
    937         }
    938 
    939         public function test_sync_stale_compaction_is_stored_as_update_when_newer_compaction_exists() {
    940                 wp_set_current_user( self::$editor_id );
    941 
    942                 $room   = $this->get_post_room();
    943                 $update = array(
    944                         'type' => 'update',
    945                         'data' => 'dGVzdA==',
    946                 );
    947 
    948                 // Client 1 sends an update to seed the room.
    949                 $response = $this->dispatch_sync(
    950                         array(
    951                                 $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ),
    952                         )
    953                 );
    954 
    955                 $end_cursor = $response->get_data()['rooms'][0]['end_cursor'];
    956 
    957                 // Client 2 sends a compaction at the current cursor.
    958                 $compaction = array(
    959                         'type' => 'compaction',
    960                         'data' => 'Y29tcGFjdGVk',
    961                 );
    962 
    963                 $this->dispatch_sync(
    964                         array(
    965                                 $this->build_room( $room, 2, $end_cursor, array( 'user' => 'c2' ), array( $compaction ) ),
    966                         )
    967                 );
    968 
    969                 /*
    970                  * Client 3 sends a stale compaction at cursor 0 (mirroring two offline
    971                  * clients that reconnect from the same baseline cursor). The server
    972                  * cannot run remove_updates_before_cursor because client 2 has already
    973                  * advanced the frontier, but the bytes must still be stored as a
    974                  * regular update so client 3's operations can propagate to other
    975                  * clients via Yjs state-as-update merging.
    976                  */
    977                 $stale_compaction = array(
    978                         'type' => 'compaction',
    979                         'data' => 'c3RhbGU=',
    980                 );
    981                 $response         = $this->dispatch_sync(
    982                         array(
    983                                 $this->build_room( $room, 3, 0, array( 'user' => 'c3' ), array( $stale_compaction ) ),
    984                         )
    985                 );
    986 
    987                 $this->assertSame( 200, $response->get_status() );
    988 
    989                 /*
    990                  * Verify the newer compaction is preserved AND the stale compaction's
    991                  * bytes were persisted (now as type=update so subsequent compactions
    992                  * don't trip the has_newer_compaction check).
    993                  */
    994                 $response = $this->dispatch_sync(
    995                         array(
    996                                 $this->build_room( $room, 4, 0, array( 'user' => 'c4' ) ),
    997                         )
    998                 );
    999                 $updates  = $response->get_data()['rooms'][0]['updates'];
    1000 
    1001                 $update_data = wp_list_pluck( $updates, 'data' );
    1002                 $this->assertContains( 'Y29tcGFjdGVk', $update_data, 'The newer compaction should be preserved.' );
    1003                 $this->assertContains( 'c3RhbGU=', $update_data, 'The stale compaction bytes should be stored so client 3\'s operations propagate.' );
    1004 
    1005                 $stale_entry = null;
    1006                 foreach ( $updates as $entry ) {
    1007                         if ( 'c3RhbGU=' === $entry['data'] ) {
    1008                                 $stale_entry = $entry;
    1009                                 break;
    1010                         }
    1011                 }
    1012                 $this->assertNotNull( $stale_entry, 'The stale compaction entry should be present in the room.' );
    1013                 $this->assertSame( 'update', $stale_entry['type'], 'The stale compaction should be stored as type=update, not type=compaction.' );
    1014         }
    1015 
    1016         /*
    1017          * Awareness tests.
    1018          */
    1019 
    1020         public function test_sync_awareness_returned() {
    1021                 wp_set_current_user( self::$editor_id );
    1022 
    1023                 $awareness = array( 'name' => 'Editor' );
    1024                 $response  = $this->dispatch_sync(
    1025                         array(
    1026                                 $this->build_room( $this->get_post_room(), 1, 0, $awareness ),
    1027                         )
    1028                 );
    1029 
    1030                 $data = $response->get_data();
    1031                 $this->assertArrayHasKey( 1, $data['rooms'][0]['awareness'] );
    1032                 $this->assertSame( $awareness, $data['rooms'][0]['awareness'][1] );
    1033         }
    1034 
    1035         public function test_sync_awareness_shows_multiple_clients() {
    1036                 wp_set_current_user( self::$editor_id );
    1037 
    1038                 $room = $this->get_post_room();
    1039 
    1040                 // Client 1 connects.
    1041                 $this->dispatch_sync(
    1042                         array(
    1043                                 $this->build_room( $room, 1, 0, array( 'name' => 'Client 1' ) ),
    1044                         )
    1045                 );
    1046 
    1047                 // Client 2 connects.
    1048                 $response = $this->dispatch_sync(
    1049                         array(
    1050                                 $this->build_room( $room, 2, 0, array( 'name' => 'Client 2' ) ),
    1051                         )
    1052                 );
    1053 
    1054                 $data      = $response->get_data();
    1055                 $awareness = $data['rooms'][0]['awareness'];
    1056 
    1057                 $this->assertArrayHasKey( 1, $awareness );
    1058                 $this->assertArrayHasKey( 2, $awareness );
    1059                 $this->assertSame( array( 'name' => 'Client 1' ), $awareness[1] );
    1060                 $this->assertSame( array( 'name' => 'Client 2' ), $awareness[2] );
    1061         }
    1062 
    1063         public function test_sync_awareness_updates_existing_client() {
    1064                 wp_set_current_user( self::$editor_id );
    1065 
    1066                 $room = $this->get_post_room();
    1067 
    1068                 // Client 1 connects with initial awareness.
    1069                 $this->dispatch_sync(
    1070                         array(
    1071                                 $this->build_room( $room, 1, 0, array( 'cursor' => 'start' ) ),
    1072                         )
    1073                 );
    1074 
    1075                 // Client 1 updates its awareness.
    1076                 $response = $this->dispatch_sync(
    1077                         array(
    1078                                 $this->build_room( $room, 1, 0, array( 'cursor' => 'updated' ) ),
    1079                         )
    1080                 );
    1081 
    1082                 $data      = $response->get_data();
    1083                 $awareness = $data['rooms'][0]['awareness'];
    1084 
    1085                 // Should have exactly one entry for client 1 with updated state.
    1086                 $this->assertCount( 1, $awareness );
    1087                 $this->assertSame( array( 'cursor' => 'updated' ), $awareness[1] );
    1088         }
    1089 
    1090         public function test_sync_awareness_client_id_cannot_be_used_by_another_user() {
    1091                 wp_set_current_user( self::$editor_id );
    1092 
    1093                 $room = $this->get_post_room();
    1094 
    1095                 // Editor establishes awareness with client_id 1.
    1096                 $this->dispatch_sync(
    1097                         array(
    1098                                 $this->build_room( $room, 1, 0, array( 'name' => 'Editor' ) ),
    1099                         )
    1100                 );
    1101 
    1102                 // A different user tries to use the same client_id.
    1103                 $editor_id_2 = self::factory()->user->create( array( 'role' => 'editor' ) );
    1104                 wp_set_current_user( $editor_id_2 );
    1105 
    1106                 $response = $this->dispatch_sync(
    1107                         array(
    1108                                 $this->build_room( $room, 1, 0, array( 'name' => 'Impostor' ) ),
    1109                         )
    1110                 );
    1111 
    1112                 $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
    1113         }
    1114 
    1115         /*
    1116          * Multiple rooms tests.
    1117          */
    1118 
    1119         public function test_sync_multiple_rooms_in_single_request() {
    1120                 wp_set_current_user( self::$editor_id );
    1121 
    1122                 $room1 = $this->get_post_room();
    1123                 $room2 = 'taxonomy/category';
    1124 
    1125                 $response = $this->dispatch_sync(
    1126                         array(
    1127                                 $this->build_room( $room1 ),
    1128                                 $this->build_room( $room2 ),
    1129                         )
    1130                 );
    1131 
    1132                 $this->assertSame( 200, $response->get_status() );
    1133 
    1134                 $data = $response->get_data();
    1135                 $this->assertCount( 2, $data['rooms'] );
    1136                 $this->assertSame( $room1, $data['rooms'][0]['room'] );
    1137                 $this->assertSame( $room2, $data['rooms'][1]['room'] );
    1138         }
    1139 
    1140         public function test_sync_rooms_are_isolated() {
    1141                 wp_set_current_user( self::$editor_id );
    1142 
    1143                 $post_id_2 = self::factory()->post->create( array( 'post_author' => self::$editor_id ) );
    1144                 $room1     = $this->get_post_room();
    1145                 $room2     = 'postType/post:' . $post_id_2;
    1146 
    1147                 $update = array(
    1148                         'type' => 'update',
    1149                         'data' => 'cm9vbTEgb25seQ==',
    1150                 );
    1151 
    1152                 // Client 1 sends an update to room 1 only.
    1153                 $this->dispatch_sync(
    1154                         array(
    1155                                 $this->build_room( $room1, 1, 0, array( 'user' => 'client1' ), array( $update ) ),
    1156                         )
    1157                 );
    1158 
    1159                 // Client 2 queries both rooms.
    1160                 $response = $this->dispatch_sync(
    1161                         array(
    1162                                 $this->build_room( $room1, 2, 0 ),
    1163                                 $this->build_room( $room2, 2, 0 ),
    1164                         )
    1165                 );
    1166 
    1167                 $data = $response->get_data();
    1168 
    1169                 // Room 1 should have the update.
    1170                 $this->assertNotEmpty( $data['rooms'][0]['updates'] );
    1171 
    1172                 // Room 2 should have no updates.
    1173                 $this->assertEmpty( $data['rooms'][1]['updates'] );
    1174         }
    1175 }
  • trunk/tests/qunit/fixtures/wp-api-generated.js

    r62210 r62334  
    1108611086                            "required": false
    1108711087                        },
    11088                         "wp_collaboration_enabled": {
    11089                             "title": "",
    11090                             "description": "Enable Real-Time Collaboration",
    11091                             "type": "boolean",
    11092                             "required": false
    11093                         },
    1109411088                        "posts_per_page": {
    1109511089                            "title": "Maximum posts per page",
     
    1455714551    "default_category": 1,
    1455814552    "default_post_format": "0",
    14559     "wp_collaboration_enabled": false,
    1456014553    "posts_per_page": 10,
    1456114554    "show_on_front": "posts",
Note: See TracChangeset for help on using the changeset viewer.