@@ -308,7 +308,7 @@ public function test_sync_end_cursor_is_positive_integer() {
308308
309309 $ data = $ response ->get_data ();
310310 $ this ->assertIsInt ( $ data ['rooms ' ][0 ]['end_cursor ' ] );
311- $ this ->assertGreaterThan ( 0 , $ data ['rooms ' ][0 ]['end_cursor ' ] );
311+ $ this ->assertGreaterThanOrEqual ( 0 , $ data ['rooms ' ][0 ]['end_cursor ' ] );
312312 }
313313
314314 public function test_sync_empty_updates_returns_zero_total () {
@@ -539,6 +539,162 @@ public function test_sync_total_updates_increments() {
539539 $ this ->assertSame ( 3 , $ data ['rooms ' ][0 ]['total_updates ' ] );
540540 }
541541
542+ public function test_sync_cursor_does_not_skip_update_inserted_during_fetch_window () {
543+ global $ wpdb ;
544+
545+ wp_set_current_user ( self ::$ editor_id );
546+
547+ $ room = $ this ->get_post_room ();
548+ $ storage = new WP_Sync_Post_Meta_Storage ();
549+
550+ $ seed_update = array (
551+ 'client_id ' => 1 ,
552+ 'type ' => 'update ' ,
553+ 'data ' => 'c2VlZA== ' ,
554+ );
555+
556+ $ this ->assertTrue ( $ storage ->add_update ( $ room , $ seed_update ) );
557+
558+ $ initial_updates = $ storage ->get_updates_after_cursor ( $ room , 0 );
559+ $ baseline_cursor = $ storage ->get_cursor ( $ room );
560+
561+ $ this ->assertCount ( 1 , $ initial_updates );
562+ $ this ->assertSame ( $ seed_update , $ initial_updates [0 ] );
563+ $ this ->assertGreaterThan ( 0 , $ baseline_cursor );
564+
565+ $ storage_posts = get_posts (
566+ array (
567+ 'post_type ' => WP_Sync_Post_Meta_Storage::POST_TYPE ,
568+ 'posts_per_page ' => 1 ,
569+ 'post_status ' => 'publish ' ,
570+ 'name ' => md5 ( $ room ),
571+ 'fields ' => 'ids ' ,
572+ )
573+ );
574+ $ storage_post_id = array_first ( $ storage_posts );
575+
576+ $ this ->assertIsInt ( $ storage_post_id );
577+
578+ $ injected_update = array (
579+ 'client_id ' => 9999 ,
580+ 'type ' => 'update ' ,
581+ 'data ' => base64_encode ( 'injected-during-fetch ' ),
582+ );
583+
584+ // Clear the room state cache so the stats query actually executes
585+ // and the proxy can intercept it to simulate the race condition.
586+ wp_cache_delete ( 'sync_room_state_ ' . md5 ( $ room ), 'sync ' );
587+
588+ $ original_wpdb = $ wpdb ;
589+ $ proxy_wpdb = new class ( $ original_wpdb , $ storage_post_id , $ injected_update ) {
590+ private $ wpdb ;
591+ private $ storage_post_id ;
592+ private $ injected_update ;
593+ public $ postmeta ;
594+ public $ did_inject = false ;
595+
596+ public function __construct ( $ wpdb , int $ storage_post_id , array $ injected_update ) {
597+ $ this ->wpdb = $ wpdb ;
598+ $ this ->storage_post_id = $ storage_post_id ;
599+ $ this ->injected_update = $ injected_update ;
600+ $ this ->postmeta = $ wpdb ->postmeta ;
601+ }
602+
603+ // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Proxy forwards fully prepared core queries.
604+ public function prepare ( ...$ args ) {
605+ return $ this ->wpdb ->prepare ( ...$ args );
606+ }
607+
608+ public function get_row ( $ query = null , $ output = OBJECT , $ y = 0 ) {
609+ $ result = $ this ->wpdb ->get_row ( $ query , $ output , $ y );
610+
611+ $ this ->maybe_inject_after_sync_query ( $ query );
612+
613+ return $ result ;
614+ }
615+
616+ public function get_var ( $ query = null , $ x = 0 , $ y = 0 ) {
617+ $ result = $ this ->wpdb ->get_var ( $ query , $ x , $ y );
618+
619+ $ this ->maybe_inject_after_sync_query ( $ query );
620+
621+ return $ result ;
622+ }
623+
624+ public function get_results ( $ query = null , $ output = OBJECT ) {
625+ return $ this ->wpdb ->get_results ( $ query , $ output );
626+ }
627+ // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
628+
629+ public function __call ( $ name , $ arguments ) {
630+ return $ this ->wpdb ->$ name ( ...$ arguments );
631+ }
632+
633+ public function __get ( $ name ) {
634+ return $ this ->wpdb ->$ name ;
635+ }
636+
637+ public function __set ( $ name , $ value ) {
638+ $ this ->wpdb ->$ name = $ value ;
639+ }
640+
641+ private function inject_update (): void {
642+ if ( $ this ->did_inject ) {
643+ return ;
644+ }
645+
646+ $ this ->did_inject = true ;
647+
648+ add_post_meta (
649+ $ this ->storage_post_id ,
650+ WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY ,
651+ $ this ->injected_update ,
652+ false
653+ );
654+ }
655+
656+ private function maybe_inject_after_sync_query ( $ query ): void {
657+ if ( $ this ->did_inject || ! is_string ( $ query ) ) {
658+ return ;
659+ }
660+
661+ $ targets_postmeta = false !== strpos ( $ query , $ this ->postmeta );
662+ $ targets_post_id = 1 === preg_match ( '/\bpost_id\s*=\s* ' . (int ) $ this ->storage_post_id . '\b/ ' , $ query );
663+ $ targets_meta_key = 1 === preg_match (
664+ "/\bmeta_key\s*=\s*' " . preg_quote ( WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY , '/ ' ) . "'/ " ,
665+ $ query
666+ );
667+
668+ if ( $ targets_postmeta && $ targets_post_id && $ targets_meta_key ) {
669+ $ this ->inject_update ();
670+ }
671+ }
672+ };
673+
674+ $ wpdb = $ proxy_wpdb ;
675+ try {
676+ $ race_updates = $ storage ->get_updates_after_cursor ( $ room , $ baseline_cursor );
677+ $ race_cursor = $ storage ->get_cursor ( $ room );
678+ } finally {
679+ $ wpdb = $ original_wpdb ;
680+ }
681+
682+ $ this ->assertTrue ( $ proxy_wpdb ->did_inject , 'Expected race-window update injection to occur. ' );
683+ $ this ->assertEmpty ( $ race_updates );
684+ $ this ->assertSame ( $ baseline_cursor , $ race_cursor );
685+
686+ // Clear the room state cache since the injected update bypassed
687+ // add_update() and its cache invalidation.
688+ wp_cache_delete ( 'sync_room_state_ ' . md5 ( $ room ), 'sync ' );
689+
690+ $ follow_up_updates = $ storage ->get_updates_after_cursor ( $ room , $ race_cursor );
691+ $ follow_up_cursor = $ storage ->get_cursor ( $ room );
692+
693+ $ this ->assertCount ( 1 , $ follow_up_updates );
694+ $ this ->assertSame ( $ injected_update , $ follow_up_updates [0 ] );
695+ $ this ->assertGreaterThan ( $ race_cursor , $ follow_up_cursor );
696+ }
697+
542698 /*
543699 * Compaction tests.
544700 */
0 commit comments