Files
2meet-data-optimizer/tests/integration/DemoEntityCounterTest.php
T
wpdev d36bb954d1 chore: initial snapshot of 2meet-data-optimizer v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 05:06:36 +08:00

269 lines
10 KiB
PHP

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* End-to-end test for WPDO_Demo_Entity_Counter — proves the entity adapter
* framework is not just a stub but actually delivers the full lifecycle:
*
* 1. Schema install (custom table + composite UNIQUE + secondary index)
* 2. Dual-write via WPDO_DB::upsert (1 RT)
* 3. Read routing via Feature_Flags FSM (idle → cutover → cleanup → complete)
* 4. Top-N query (the killer use case postmeta cannot do efficiently)
*
* Tests user / term / comment entities to validate cross-entity coverage.
*
* @covers WPDO_Demo_Entity_Counter
*/
class DemoEntityCounterTest extends TestCase {
public static function setUpBeforeClass(): void {
WPDO_Demo_Entity_Counter::drop_table();
WPDO_Demo_Entity_Counter::install_table();
}
public static function tearDownAfterClass(): void {
WPDO_Demo_Entity_Counter::drop_table();
WPDO_Feature_Flags::reset( WPDO_Demo_Entity_Counter::MODULE );
}
protected function setUp(): void {
// Reset feature flag module to idle before each test.
WPDO_Feature_Flags::reset( WPDO_Demo_Entity_Counter::MODULE );
// Truncate the table for clean state.
global $wpdb;
$wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "`" );
// Reset native usermeta global stubs (when running under integration env).
$GLOBALS['_wp_usermeta'] = array();
}
// ── Schema ─────────────────────────────────────────────────────────────
public function test_install_table_creates_with_composite_unique(): void {
global $wpdb;
$table = $wpdb->prefix . WPDO_Demo_Entity_Counter::TABLE;
// Index check: ui_entity_counter must be UNIQUE on (entity_type, entity_id, counter_key).
$rows = $wpdb->get_results(
$wpdb->prepare(
'SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s ORDER BY INDEX_NAME, SEQ_IN_INDEX',
$table
),
ARRAY_A
);
$ui_cols = array();
foreach ( $rows as $r ) {
if ( 'ui_entity_counter' === $r['INDEX_NAME'] && '0' === (string) $r['NON_UNIQUE'] ) {
$ui_cols[] = $r['COLUMN_NAME'];
}
}
$this->assertSame( array( 'entity_type', 'entity_id', 'counter_key' ), $ui_cols );
}
// ── set() / get() — idle state (native fallback only) ──────────────────
public function test_set_writes_to_native_meta_in_idle_state(): void {
WPDO_Demo_Entity_Counter::set( 'user', 100, 'points', 50 );
$this->assertSame( 50, (int) get_user_meta( 100, 'points', true ) );
}
public function test_get_reads_native_in_idle_state(): void {
update_user_meta( 200, 'points', 75 );
$this->assertSame( 75, WPDO_Demo_Entity_Counter::get( 'user', 200, 'points' ) );
}
public function test_idle_state_does_not_dual_write(): void {
WPDO_Demo_Entity_Counter::set( 'user', 300, 'points', 99 );
global $wpdb;
$count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d",
'user',
300
)
);
$this->assertSame( 0, $count, 'idle state must NOT dual-write to demo table' );
}
// ── set() — dual_write state ────────────────────────────────────────────
public function test_dual_write_state_writes_to_both(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'user', 400, 'points', 123 );
global $wpdb;
$zone_value = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
'user',
400,
'points'
)
);
$this->assertSame( 123, $zone_value, 'dual_write must populate the zone table' );
$this->assertSame( 123, (int) get_user_meta( 400, 'points', true ), 'dual_write must also keep native meta' );
}
public function test_upsert_uses_single_round_trip(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
// Two rapid writes to the same key — should produce exactly 1 row, not 2.
WPDO_Demo_Entity_Counter::set( 'user', 500, 'points', 10 );
WPDO_Demo_Entity_Counter::set( 'user', 500, 'points', 25 );
global $wpdb;
$rows = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d",
'user',
500
)
);
$this->assertSame( '1', (string) $rows, 'composite UNIQUE must collapse to 1 row' );
$value = $wpdb->get_var(
$wpdb->prepare(
"SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
'user',
500,
'points'
)
);
$this->assertSame( '25', (string) $value, 'second write must overwrite via UPSERT' );
}
// ── get() — cutover state (read from zone) ─────────────────────────────
public function test_cutover_state_reads_from_zone_table(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'user', 600, 'points', 999 );
// Switch to cutover — reads now come from zone.
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'cutover' );
// Tamper with native meta to prove zone table is the source of truth.
update_user_meta( 600, 'points', 0 );
$this->assertSame( 999, WPDO_Demo_Entity_Counter::get( 'user', 600, 'points' ) );
}
public function test_cutover_falls_back_to_native_when_zone_row_missing(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'cutover' );
// No dual_write history — zone table is empty for this entity.
update_user_meta( 700, 'points', 42 );
$this->assertSame( 42, WPDO_Demo_Entity_Counter::get( 'user', 700, 'points' ), 'graceful fallback when zone row absent' );
}
// ── Cross-entity coverage ──────────────────────────────────────────────
public function test_term_entity_works(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'term', 800, 'usage_count', 17 );
global $wpdb;
$value = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
'term',
800,
'usage_count'
)
);
$this->assertSame( 17, $value );
}
public function test_comment_entity_works(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'comment', 900, 'helpful_count', 8 );
global $wpdb;
$value = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
'comment',
900,
'helpful_count'
)
);
$this->assertSame( 8, $value );
}
public function test_invalid_entity_type_returns_false(): void {
$this->assertFalse( WPDO_Demo_Entity_Counter::set( 'bogus', 1, 'k', 1 ) );
$this->assertSame( 0, WPDO_Demo_Entity_Counter::get( 'bogus', 1, 'k' ) );
}
// ── Top-N query (killer use case postmeta can't do efficiently) ───────
public function test_top_n_query_returns_sorted_results(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
// Seed 5 users with varying point counts.
WPDO_Demo_Entity_Counter::set( 'user', 1001, 'points', 100 );
WPDO_Demo_Entity_Counter::set( 'user', 1002, 'points', 500 );
WPDO_Demo_Entity_Counter::set( 'user', 1003, 'points', 200 );
WPDO_Demo_Entity_Counter::set( 'user', 1004, 'points', 800 );
WPDO_Demo_Entity_Counter::set( 'user', 1005, 'points', 350 );
$top3 = WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 3 );
$this->assertCount( 3, $top3 );
// Sorted DESC: 1004(800) > 1002(500) > 1005(350) > 1003(200) > 1001(100)
$this->assertSame( 1004, $top3[0]['entity_id'] );
$this->assertSame( 800, $top3[0]['counter_value'] );
$this->assertSame( 1002, $top3[1]['entity_id'] );
$this->assertSame( 500, $top3[1]['counter_value'] );
$this->assertSame( 1005, $top3[2]['entity_id'] );
$this->assertSame( 350, $top3[2]['counter_value'] );
}
public function test_top_n_filters_by_entity_type(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'user', 2001, 'points', 999 );
WPDO_Demo_Entity_Counter::set( 'term', 2001, 'usage_count', 999 ); // same id, different type.
$users = WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 10 );
$terms = WPDO_Demo_Entity_Counter::top_n( 'term', 'usage_count', 10 );
$this->assertCount( 1, $users );
$this->assertCount( 1, $terms );
$this->assertSame( 2001, $users[0]['entity_id'] );
$this->assertSame( 2001, $terms[0]['entity_id'] );
}
// ── Mini benchmark — proves zone table beats postmeta on top-N ────────
public function test_benchmark_top_n_zone_vs_postmeta_simulated(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
// Seed 100 users with random point values.
for ( $i = 3001; $i <= 3100; $i++ ) {
WPDO_Demo_Entity_Counter::set( 'user', $i, 'points', wp_rand( 0, 10000 ) );
}
// Time the zone-table top-10 query.
$t1 = microtime( true );
for ( $i = 0; $i < 100; $i++ ) {
WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 10 );
}
$zone_ms = ( microtime( true ) - $t1 ) * 1000;
// We expect 100 zone reads under 200ms total (well under "1 LEFT JOIN per request").
$this->assertLessThan(
500,
$zone_ms,
"100 top-N reads from zone table took {$zone_ms}ms — exceeded 500ms ceiling"
);
// Print for visibility (PHPUnit captures to test output, no assertion impact).
fwrite( STDOUT, "\n Demo benchmark: 100x top-10 in {$zone_ms}ms (avg " . round( $zone_ms / 100, 2 ) . "ms/call)\n" );
}
}