diff --git a/includes/zones/class-tmdo-zone-router.php b/includes/zones/class-tmdo-zone-router.php index f941c60..8136cb2 100644 --- a/includes/zones/class-tmdo-zone-router.php +++ b/includes/zones/class-tmdo-zone-router.php @@ -104,7 +104,10 @@ class TMDO_Zone_Router { * * Callers only need post_id and post_type; zone enumeration and per-zone * error isolation are handled here. Hot and Cold are skipped when the - * post_type has no registered fields in that zone (table may not exist). + * post_type has no registered fields in that zone, or when the zone table + * was never created (fields are registered long before a module runs). + * Module state is not checked: a table left by a rolled-back module still + * gets its rows for deleted posts removed. * * @param int $post_id Post ID being deleted. * @param string $post_type Post type slug. @@ -113,7 +116,7 @@ class TMDO_Zone_Router { public static function delete_post( int $post_id, string $post_type ): void { $registry = TMDO_Schema_Registry::instance(); - if ( ! empty( $registry->get_hot_columns( $post_type ) ) ) { + if ( ! empty( $registry->get_hot_columns( $post_type ) ) && TMDO_Schema_Manager::table_exists( TMDO_Zone_Hot::table( $post_type ) ) ) { try { TMDO_Zone_Hot::delete( $post_id, $post_type ); } catch ( \Throwable $e ) { @@ -121,7 +124,7 @@ class TMDO_Zone_Router { } } - if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) ) { + if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) && TMDO_Schema_Manager::table_exists( TMDO_Zone_Cold::table( $post_type ) ) ) { try { TMDO_Zone_Cold::delete( $post_id, $post_type ); } catch ( \Throwable $e ) { diff --git a/tests/live/README.md b/tests/live/README.md index 6963076..a98aa7c 100644 --- a/tests/live/README.md +++ b/tests/live/README.md @@ -38,6 +38,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test: |---|---| | `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) | | `serialized` | array meta values are stored serialized in flat tables, never "Array" | +| `zero_side_effect` | zone tables that were never created are never queried on save / delete | ## Tools (`tools/`) diff --git a/tests/live/suites/zero_side_effect.php b/tests/live/suites/zero_side_effect.php new file mode 100644 index 0000000..dbbca04 --- /dev/null +++ b/tests/live/suites/zero_side_effect.php @@ -0,0 +1,46 @@ + $id, 'post_title' => 'tmdo-live-updated' ) ); + wp_delete_post( $id, true ); + } finally { + remove_filter( 'query', $spy ); + } + + tmdo_live_assert_same( array(), $touched, 'queries on missing zone tables' ); + } +); diff --git a/tests/unit/ZoneRouterTest.php b/tests/unit/ZoneRouterTest.php index 5d8e9e8..80167f1 100644 --- a/tests/unit/ZoneRouterTest.php +++ b/tests/unit/ZoneRouterTest.php @@ -21,9 +21,69 @@ class ZoneRouterTest extends TestCase { self::$last_query = ''; self::$last_delete = []; self::$get_var_return = null; + WPDO_Schema_Manager::flush_table_exists_cache(); $this->setup_wpdb_mock(); } + /** + * $wpdb mock recording delete() tables; SHOW TABLES reports $existing. + * + * @param string[] $existing Tables that exist. + */ + private function delete_logger( array &$captured, array $existing ): void { + global $wpdb; + $wpdb = new class( $captured, $existing ) { + public string $prefix = 'wp_'; + public array $log; + public function __construct( array &$ref, private array $existing ) { $this->log = &$ref; } + public function prepare( string $s, mixed ...$a ): string { return str_replace( '%s', (string) ( $a[0] ?? '' ), $s ); } + public function query( string $s ): int { return 1; } + public function get_var( string $s ): mixed { + foreach ( $this->existing as $t ) { + if ( "SHOW TABLES LIKE {$t}" === $s ) { + return $t; + } + } + return null; + } + public function insert( string $t, array $d, mixed $f = null ): int { return 1; } + public function delete( string $table, array $where, array $formats ): void { + $this->log[] = $table; + } + }; + } + + private function register_hot_and_cold(): void { + $sr = new ReflectionClass( WPDO_Schema_Registry::class ); + $sr->getProperty( 'instance' )->setValue( null, null ); + WPDO_Schema_Registry::instance()->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'type' => 'decimal' ) ); + WPDO_Schema_Registry::instance()->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_images', 'zone' => 'cold' ) ); + } + + /** Registered fields do not mean the zone tables exist (zero side effect). */ + public function test_delete_post_skips_hot_and_cold_tables_that_do_not_exist(): void { + $this->register_hot_and_cold(); + $captured = array(); + $this->delete_logger( $captured, array() ); + + WPDO_Zone_Router::delete_post( 3, 'hp_listing' ); + + $this->assertNotContains( 'wp_wpdo_hot_hp_listing', $captured ); + $this->assertNotContains( 'wp_wpdo_cold_hp_listing', $captured ); + $this->assertContains( 'wp_wpdo_warm', $captured ); + } + + public function test_delete_post_deletes_cold_rows_when_the_table_exists(): void { + $this->register_hot_and_cold(); + $captured = array(); + $this->delete_logger( $captured, array( 'wp_wpdo_cold_hp_listing' ) ); + + WPDO_Zone_Router::delete_post( 4, 'hp_listing' ); + + $this->assertContains( 'wp_wpdo_cold_hp_listing', $captured ); + $this->assertNotContains( 'wp_wpdo_hot_hp_listing', $captured ); + } + private function setup_wpdb_mock(): void { global $wpdb; $wpdb = new class { @@ -204,19 +264,7 @@ class ZoneRouterTest extends TestCase { ) ); $captured = array(); - global $wpdb; - $wpdb = new class( $captured ) { - public string $prefix = 'wp_'; - public array $log; - public function __construct( array &$ref ) { $this->log = &$ref; } - public function prepare( string $s, mixed ...$a ): string { return $s; } - public function query( string $s ): int { return 1; } - public function get_var( string $s ): mixed { return null; } - public function insert( string $t, array $d, mixed $f = null ): int { return 1; } - public function delete( string $table, array $where, array $formats ): void { - $this->log[] = $table; - } - }; + $this->delete_logger( $captured, array( 'wp_wpdo_hot_hp_listing' ) ); WPDO_Zone_Router::delete_post( 10, 'hp_listing' );