Skip to content

Commit 0e73dcd

Browse files
committed
Consolidate post meta storage tests
1 parent a0f1fdd commit 0e73dcd

2 files changed

Lines changed: 274 additions & 291 deletions

File tree

tests/phpunit/tests/collaboration/wpSyncPostMetaStorageCache.php renamed to tests/phpunit/tests/collaboration/wpSyncPostMetaStorage.php

Lines changed: 274 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
<?php
22
/**
3-
* Tests that WP_Sync_Post_Meta_Storage bypasses WordPress post meta caches.
3+
* Tests for the WP_Sync_Post_Meta_Storage class.
44
*
5-
* The storage class uses direct database queries instead of the post meta API
6-
* to avoid cache invalidation side effects (wp_cache_set_posts_last_changed()
7-
* and wp_cache_delete() calls). These tests verify that contract.
5+
* Covers the storage implementation contract: cache bypass, data integrity,
6+
* malformed data handling, and race-condition safety.
87
*
98
* @package WordPress
109
* @subpackage Collaboration
1110
*
1211
* @group collaboration
1312
* @group cache
1413
*/
15-
class Tests_Collaboration_WpSyncPostMetaStorageCache extends WP_UnitTestCase {
14+
class Tests_Collaboration_WpSyncPostMetaStorage extends WP_UnitTestCase {
1615

1716
protected static $editor_id;
1817
protected static $post_id;
@@ -274,6 +273,30 @@ public function test_get_awareness_state_does_not_prime_post_meta_cache() {
274273
);
275274
}
276275

276+
public function test_get_updates_after_cursor_does_not_prime_post_meta_cache() {
277+
$storage = new WP_Sync_Post_Meta_Storage();
278+
$room = $this->get_room();
279+
$storage_post_id = $this->create_storage_post( $storage, $room );
280+
281+
// Clear any existing cache.
282+
wp_cache_delete( $storage_post_id, 'post_meta' );
283+
$this->assertFalse(
284+
wp_cache_get( $storage_post_id, 'post_meta' ),
285+
'Post meta cache should be empty before read.'
286+
);
287+
288+
$storage->get_updates_after_cursor( $room, 0 );
289+
290+
$this->assertFalse(
291+
wp_cache_get( $storage_post_id, 'post_meta' ),
292+
'get_updates_after_cursor() must not prime the post meta cache.'
293+
);
294+
}
295+
296+
/*
297+
* Data integrity tests.
298+
*/
299+
277300
public function test_get_updates_after_cursor_drops_malformed_json() {
278301
global $wpdb;
279302

@@ -318,23 +341,260 @@ public function test_get_updates_after_cursor_drops_malformed_json() {
318341
$this->assertSame( $valid_update_2, $updates[1] );
319342
}
320343

321-
public function test_get_updates_after_cursor_does_not_prime_post_meta_cache() {
344+
/*
345+
* Race-condition tests.
346+
*
347+
* These use a $wpdb proxy to inject concurrent writes between internal
348+
* query steps, verifying that the cursor-bounded query window prevents
349+
* data loss.
350+
*/
351+
352+
public function test_cursor_does_not_skip_update_inserted_during_fetch_window() {
353+
global $wpdb;
354+
322355
$storage = new WP_Sync_Post_Meta_Storage();
323356
$room = $this->get_room();
324357
$storage_post_id = $this->create_storage_post( $storage, $room );
325358

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.'
359+
$seed_update = array(
360+
'client_id' => 1,
361+
'type' => 'update',
362+
'data' => 'c2VlZA==',
363+
);
364+
365+
$this->assertTrue( $storage->add_update( $room, $seed_update ) );
366+
367+
$initial_updates = $storage->get_updates_after_cursor( $room, 0 );
368+
$baseline_cursor = $storage->get_cursor( $room );
369+
370+
// The seed from create_storage_post() plus the one we just added.
371+
$this->assertGreaterThan( 0, $baseline_cursor );
372+
373+
$injected_update = array(
374+
'client_id' => 9999,
375+
'type' => 'update',
376+
'data' => base64_encode( 'injected-during-fetch' ),
331377
);
332378

379+
$original_wpdb = $wpdb;
380+
$proxy_wpdb = new class( $original_wpdb, $storage_post_id, $injected_update ) {
381+
private $wpdb;
382+
private $storage_post_id;
383+
private $injected_update;
384+
public $postmeta;
385+
public $did_inject = false;
386+
387+
public function __construct( $wpdb, int $storage_post_id, array $injected_update ) {
388+
$this->wpdb = $wpdb;
389+
$this->storage_post_id = $storage_post_id;
390+
$this->injected_update = $injected_update;
391+
$this->postmeta = $wpdb->postmeta;
392+
}
393+
394+
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Proxy forwards fully prepared core queries.
395+
public function prepare( ...$args ) {
396+
return $this->wpdb->prepare( ...$args );
397+
}
398+
399+
public function get_row( $query = null, $output = OBJECT, $y = 0 ) {
400+
$result = $this->wpdb->get_row( $query, $output, $y );
401+
402+
$this->maybe_inject_after_sync_query( $query );
403+
404+
return $result;
405+
}
406+
407+
public function get_var( $query = null, $x = 0, $y = 0 ) {
408+
$result = $this->wpdb->get_var( $query, $x, $y );
409+
410+
$this->maybe_inject_after_sync_query( $query );
411+
412+
return $result;
413+
}
414+
415+
public function get_results( $query = null, $output = OBJECT ) {
416+
return $this->wpdb->get_results( $query, $output );
417+
}
418+
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
419+
420+
public function __call( $name, $arguments ) {
421+
return $this->wpdb->$name( ...$arguments );
422+
}
423+
424+
public function __get( $name ) {
425+
return $this->wpdb->$name;
426+
}
427+
428+
public function __set( $name, $value ) {
429+
$this->wpdb->$name = $value;
430+
}
431+
432+
private function inject_update(): void {
433+
if ( $this->did_inject ) {
434+
return;
435+
}
436+
437+
$this->did_inject = true;
438+
439+
$this->wpdb->insert(
440+
$this->wpdb->postmeta,
441+
array(
442+
'post_id' => $this->storage_post_id,
443+
'meta_key' => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY,
444+
'meta_value' => wp_json_encode( $this->injected_update ),
445+
),
446+
array( '%d', '%s', '%s' )
447+
);
448+
}
449+
450+
private function maybe_inject_after_sync_query( $query ): void {
451+
if ( $this->did_inject || ! is_string( $query ) ) {
452+
return;
453+
}
454+
455+
$targets_postmeta = false !== strpos( $query, $this->postmeta );
456+
$targets_post_id = 1 === preg_match( '/\bpost_id\s*=\s*' . (int) $this->storage_post_id . '\b/', $query );
457+
$targets_meta_key = 1 === preg_match(
458+
"/\bmeta_key\s*=\s*'" . preg_quote( WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, '/' ) . "'/",
459+
$query
460+
);
461+
462+
if ( $targets_postmeta && $targets_post_id && $targets_meta_key ) {
463+
$this->inject_update();
464+
}
465+
}
466+
};
467+
468+
$wpdb = $proxy_wpdb;
469+
try {
470+
$race_updates = $storage->get_updates_after_cursor( $room, $baseline_cursor );
471+
$race_cursor = $storage->get_cursor( $room );
472+
} finally {
473+
$wpdb = $original_wpdb;
474+
}
475+
476+
$this->assertTrue( $proxy_wpdb->did_inject, 'Expected race-window update injection to occur.' );
477+
$this->assertEmpty( $race_updates );
478+
$this->assertSame( $baseline_cursor, $race_cursor );
479+
480+
$follow_up_updates = $storage->get_updates_after_cursor( $room, $race_cursor );
481+
$follow_up_cursor = $storage->get_cursor( $room );
482+
483+
$this->assertCount( 1, $follow_up_updates );
484+
$this->assertSame( $injected_update, $follow_up_updates[0] );
485+
$this->assertGreaterThan( $race_cursor, $follow_up_cursor );
486+
}
487+
488+
public function test_compaction_does_not_delete_update_inserted_during_delete() {
489+
global $wpdb;
490+
491+
$storage = new WP_Sync_Post_Meta_Storage();
492+
$room = $this->get_room();
493+
$storage_post_id = $this->create_storage_post( $storage, $room );
494+
495+
// Seed three updates so there's something to compact.
496+
for ( $i = 1; $i <= 3; $i++ ) {
497+
$this->assertTrue(
498+
$storage->add_update(
499+
$room,
500+
array(
501+
'client_id' => $i,
502+
'type' => 'update',
503+
'data' => base64_encode( "seed-$i" ),
504+
)
505+
)
506+
);
507+
}
508+
509+
// Capture the cursor after all seeds are in place.
333510
$storage->get_updates_after_cursor( $room, 0 );
511+
$compaction_cursor = $storage->get_cursor( $room );
512+
$this->assertGreaterThan( 0, $compaction_cursor );
334513

335-
$this->assertFalse(
336-
wp_cache_get( $storage_post_id, 'post_meta' ),
337-
'get_updates_after_cursor() must not prime the post meta cache.'
514+
$concurrent_update = array(
515+
'client_id' => 9999,
516+
'type' => 'update',
517+
'data' => base64_encode( 'arrived-during-compaction' ),
518+
);
519+
520+
$original_wpdb = $wpdb;
521+
$proxy_wpdb = new class( $original_wpdb, $storage_post_id, $concurrent_update ) {
522+
private $wpdb;
523+
private $storage_post_id;
524+
private $concurrent_update;
525+
public $did_inject = false;
526+
527+
public function __construct( $wpdb, int $storage_post_id, array $concurrent_update ) {
528+
$this->wpdb = $wpdb;
529+
$this->storage_post_id = $storage_post_id;
530+
$this->concurrent_update = $concurrent_update;
531+
}
532+
533+
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Proxy forwards fully prepared core queries.
534+
public function prepare( ...$args ) {
535+
return $this->wpdb->prepare( ...$args );
536+
}
537+
538+
public function query( $query ) {
539+
$result = $this->wpdb->query( $query );
540+
541+
// After the DELETE executes, inject a concurrent update via
542+
// raw SQL through the real $wpdb to avoid metadata cache
543+
// interactions while the proxy is active.
544+
if ( ! $this->did_inject
545+
&& is_string( $query )
546+
&& 0 === strpos( $query, "DELETE FROM {$this->wpdb->postmeta}" )
547+
&& false !== strpos( $query, "post_id = {$this->storage_post_id}" )
548+
) {
549+
$this->did_inject = true;
550+
$this->wpdb->insert(
551+
$this->wpdb->postmeta,
552+
array(
553+
'post_id' => $this->storage_post_id,
554+
'meta_key' => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY,
555+
'meta_value' => wp_json_encode( $this->concurrent_update ),
556+
),
557+
array( '%d', '%s', '%s' )
558+
);
559+
}
560+
561+
return $result;
562+
}
563+
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
564+
565+
public function __call( $name, $arguments ) {
566+
return $this->wpdb->$name( ...$arguments );
567+
}
568+
569+
public function __get( $name ) {
570+
return $this->wpdb->$name;
571+
}
572+
573+
public function __set( $name, $value ) {
574+
$this->wpdb->$name = $value;
575+
}
576+
};
577+
578+
// Run compaction through the proxy so the concurrent update
579+
// is injected immediately after the DELETE executes.
580+
$wpdb = $proxy_wpdb;
581+
try {
582+
$result = $storage->remove_updates_before_cursor( $room, $compaction_cursor );
583+
} finally {
584+
$wpdb = $original_wpdb;
585+
}
586+
587+
$this->assertTrue( $result );
588+
$this->assertTrue( $proxy_wpdb->did_inject, 'Expected concurrent update injection to occur.' );
589+
590+
// The concurrent update must survive the compaction delete.
591+
$updates = $storage->get_updates_after_cursor( $room, 0 );
592+
593+
$update_data = wp_list_pluck( $updates, 'data' );
594+
$this->assertContains(
595+
$concurrent_update['data'],
596+
$update_data,
597+
'Concurrent update should survive compaction.'
338598
);
339599
}
340600
}

0 commit comments

Comments
 (0)