Files
wpdev 151a14a7e8 fix(zone-warm): warm 表 UNIQUE KEY + set() 改 upsert + 原子 increment(B1/B3)
- CREATE TABLE wpdo_warm 的 meta_key varchar(255)→varchar(191),
  KEY idx_post_meta → UNIQUE KEY ui_post_meta:沒有這個唯一鍵,
  ON DUPLICATE KEY UPDATE 不會觸發,會不斷 INSERT 重複列。
  (既有站台的 ALTER 遷移原本就在 installer:1329,只有 CREATE 落後)
- Zone_Warm::set() 由 SELECT→update/insert 改為 TMDO_DB::upsert()
- 新增 Zone_Warm::increment():CAST(COALESCE(meta_value,0) AS SIGNED)+N
  原子計數,取代 get()+set() 的 read-modify-write race(A v3.3.3)
- ZoneWarmTest stub 與 ZoneWarmIntegrationTest 建表同步適配

unit 379 / integration 398 GREEN

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

218 lines
7.8 KiB
PHP

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Zone_Warm — Zone B KV table with TTL.
*
* Uses an in-memory array to simulate wpdb queries via a custom mock.
*/
class ZoneWarmTest extends TestCase {
/** In-memory warm store: post_id => meta_key => [value, expires_at] */
public static array $store = [];
protected function setUp(): void {
self::$store = [];
$this->setupWpdbMock();
}
private function setupWpdbMock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string {
$store = &ZoneWarmTest::$store;
// Normalize whitespace so multiline SQL works with regex.
$flat = preg_replace( '/\s+/', ' ', $sql );
if ( preg_match( '/SELECT id.*post_id = (\d+).*meta_key = \'([^\']+)\'/', $flat, $m ) ) {
return isset( $store[ $m[1] ][ $m[2] ] ) ? '1' : null;
}
if ( preg_match( '/SELECT meta_value.*post_id = (\d+).*meta_key = \'([^\']+)\'/', $flat, $m ) ) {
$entry = $store[ $m[1] ][ $m[2] ] ?? null;
if ( ! $entry ) {
return null;
}
if ( $entry['expires_at'] && $entry['expires_at'] < time() ) {
return null;
}
return $entry['value'];
}
return null;
}
public function get_results( string $sql, $output = null ): array {
$store = ZoneWarmTest::$store;
$result = [];
foreach ( $store as $post_id => $keys ) {
foreach ( $keys as $meta_key => $entry ) {
if ( $entry['expires_at'] && $entry['expires_at'] < time() ) {
continue;
}
$result[] = (object) [ 'meta_key' => $meta_key, 'meta_value' => $entry['value'] ];
}
}
return $result;
}
public function insert( string $table, array $data, $format = null ): int|false {
ZoneWarmTest::$store[ $data['post_id'] ][ $data['meta_key'] ] = [
'value' => $data['meta_value'],
'expires_at' => isset( $data['expires_at'] ) ? strtotime( $data['expires_at'] ) : null,
];
return 1;
}
public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
// For simplicity, find by scanning store.
foreach ( ZoneWarmTest::$store as $post_id => &$keys ) {
foreach ( $keys as $meta_key => &$entry ) {
if ( isset( $data['meta_value'] ) ) {
$entry['value'] = $data['meta_value'];
}
if ( isset( $data['expires_at'] ) ) {
$entry['expires_at'] = strtotime( $data['expires_at'] );
}
}
}
return 1;
}
public function delete( string $table, array $where, $format = null ): int|false {
$post_id = $where['post_id'] ?? null;
$meta_key = $where['meta_key'] ?? null;
if ( $post_id && $meta_key ) {
unset( ZoneWarmTest::$store[ $post_id ][ $meta_key ] );
} elseif ( $post_id ) {
unset( ZoneWarmTest::$store[ $post_id ] );
}
return 1;
}
public function query( string $sql ): int|bool {
$store = &ZoneWarmTest::$store;
$flat = preg_replace( '/\s+/', ' ', $sql );
// INSERT ... ON DUPLICATE KEY UPDATE (from set() via TMDO_DB::upsert())
// Pattern: VALUES (post_id, 'meta_key', 'value', expires_or_NULL, 'now') ON DUPLICATE KEY
if ( stripos( $flat, 'ON DUPLICATE KEY' ) !== false && stripos( $flat, 'COALESCE' ) === false ) {
if ( preg_match( "/VALUES \((\d+), '([^']+)', '([^']*)', (NULL|'[^']+'), '[^']+'\)/i", $flat, $m ) ) {
$post_id = (int) $m[1];
$meta_key = $m[2];
$meta_value = $m[3];
$expires_at = 'NULL' === $m[4] ? null : strtotime( trim( $m[4], "'" ) );
$store[ $post_id ][ $meta_key ] = array(
'value' => $meta_value,
'expires_at' => $expires_at,
);
return 1;
}
}
// INSERT ... ON DUPLICATE KEY UPDATE meta_value = CAST(COALESCE...) + N (from increment())
if ( stripos( $flat, 'COALESCE' ) !== false ) {
if ( preg_match( "/VALUES \((\d+), '([^']+)', (\d+),/i", $flat, $m ) ) {
$post_id = (int) $m[1];
$meta_key = $m[2];
$by = (int) $m[3];
$current = (int) ( $store[ $post_id ][ $meta_key ]['value'] ?? 0 );
$store[ $post_id ][ $meta_key ] = array(
'value' => (string) ( $current + $by ),
'expires_at' => null,
);
return 1;
}
}
return 0;
}
};
}
// ── set / get ────────────────────────────────────────────────────────────
public function test_set_and_get_basic_value(): void {
WPDO_Zone_Warm::set( 1, 'hp_views', '42' );
$this->assertSame( '42', WPDO_Zone_Warm::get( 1, 'hp_views' ) );
}
public function test_get_returns_null_for_missing_key(): void {
$this->assertNull( WPDO_Zone_Warm::get( 99, 'missing_key' ) );
}
public function test_set_overwrites_existing_value(): void {
WPDO_Zone_Warm::set( 1, 'hp_views', '10' );
WPDO_Zone_Warm::set( 1, 'hp_views', '20' );
// The store update mock replaces all entries for simplicity.
$this->assertNotNull( WPDO_Zone_Warm::get( 1, 'hp_views' ) );
}
// ── TTL / expiry ─────────────────────────────────────────────────────────
public function test_set_with_ttl_stores_future_expiry(): void {
WPDO_Zone_Warm::set( 1, 'hp_views', '5', 3600 );
$this->assertSame( '5', WPDO_Zone_Warm::get( 1, 'hp_views' ) );
}
public function test_expired_entry_returns_null(): void {
// Insert directly with a past expiry.
self::$store[2]['hp_flag'] = [
'value' => 'should_be_gone',
'expires_at' => time() - 1, // expired 1 second ago.
];
$this->assertNull( WPDO_Zone_Warm::get( 2, 'hp_flag' ) );
}
// ── delete ───────────────────────────────────────────────────────────────
public function test_delete_removes_key(): void {
WPDO_Zone_Warm::set( 1, 'hp_views', '7' );
WPDO_Zone_Warm::delete( 1, 'hp_views' );
$this->assertNull( WPDO_Zone_Warm::get( 1, 'hp_views' ) );
}
public function test_delete_all_removes_all_post_keys(): void {
WPDO_Zone_Warm::set( 3, 'key_a', 'val_a' );
WPDO_Zone_Warm::set( 3, 'key_b', 'val_b' );
WPDO_Zone_Warm::delete_all( 3 );
$this->assertEmpty( self::$store[3] ?? [] );
}
// ── Additional tests ─────────────────────────────────────────────────────
public function test_table_returns_warm_table_name(): void {
$this->assertSame( 'wp_wpdo_warm', WPDO_Zone_Warm::table() );
}
public function test_delete_specific_key_leaves_other_keys_intact(): void {
WPDO_Zone_Warm::set( 4, 'key_keep', 'val_keep' );
WPDO_Zone_Warm::set( 4, 'key_drop', 'val_drop' );
WPDO_Zone_Warm::delete( 4, 'key_drop' );
$this->assertNull( WPDO_Zone_Warm::get( 4, 'key_drop' ) );
// key_keep should still be readable.
$this->assertNotNull( WPDO_Zone_Warm::get( 4, 'key_keep' ) );
}
public function test_different_posts_with_same_meta_key_are_independent(): void {
WPDO_Zone_Warm::set( 5, 'shared_key', 'value_for_5' );
WPDO_Zone_Warm::set( 6, 'shared_key', 'value_for_6' );
$this->assertSame( 'value_for_5', WPDO_Zone_Warm::get( 5, 'shared_key' ) );
$this->assertSame( 'value_for_6', WPDO_Zone_Warm::get( 6, 'shared_key' ) );
}
}