From 59c9b698b787879a63b10979aeca163d44afb42a Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 31 Jul 2026 09:56:48 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E5=8A=A0=E5=85=A5=20integration=20?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6=E5=A5=97=E4=BB=B6=EF=BC=88=E9=80=A3=E7=9C=9F?= =?UTF-8?q?=E5=AF=A6=20MariaDB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 從 wp-data-optimizer v3.4.6 移植 5 個 integration 測試檔,並新增 tests/integration/bootstrap.php(複用核心的 integration bootstrap,不重複 與 WP stub)+ phpunit-integration.xml + CI integration job。 --- .gitea/workflows/test.yml | 33 +++ phpunit-integration.xml | 15 ++ .../integration/HivepressIntegrationTest.php | 197 ++++++++++++++ .../HivepressTermCommentFieldsTest.php | 131 +++++++++ .../HivepressTransientFilterTest.php | 252 ++++++++++++++++++ .../ListingMetaInterceptorTest.php | 160 +++++++++++ tests/integration/TermCommentBackfillTest.php | 247 +++++++++++++++++ tests/integration/bootstrap.php | 98 +++++++ 8 files changed, 1133 insertions(+) create mode 100644 phpunit-integration.xml create mode 100644 tests/integration/HivepressIntegrationTest.php create mode 100644 tests/integration/HivepressTermCommentFieldsTest.php create mode 100644 tests/integration/HivepressTransientFilterTest.php create mode 100644 tests/integration/ListingMetaInterceptorTest.php create mode 100644 tests/integration/TermCommentBackfillTest.php create mode 100644 tests/integration/bootstrap.php diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 41daaf9..e5a96f1 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -44,3 +44,36 @@ jobs: - name: Run unit tests working-directory: 2meet-data-optimizer-hivepress-addon run: php vendor/bin/phpunit --configuration phpunit.xml --testdox + + integration: + name: Integration Tests + runs-on: ubuntu-latest + # Host-mode runner: no service containers. Uses the host MariaDB, same as + # the core plugin's integration job. + steps: + - uses: actions/checkout@v4 + with: + path: 2meet-data-optimizer-hivepress-addon + + - name: Checkout core plugin + uses: actions/checkout@v4 + with: + repository: wpdev/2meet-data-optimizer + path: 2meet-data-optimizer + + - name: Install dependencies + working-directory: 2meet-data-optimizer-hivepress-addon + run: composer install --no-interaction --prefer-dist + + - name: Install core dev dependencies (test bootstrap needs them) + working-directory: 2meet-data-optimizer + run: composer install --no-interaction --prefer-dist + + - name: Run integration tests + working-directory: 2meet-data-optimizer-hivepress-addon + env: + TMDO_TEST_DB_HOST: 127.0.0.1 + TMDO_TEST_DB_USER: dbo + TMDO_TEST_DB_PASS: ${{ secrets.TMDO_TEST_DB_PASS }} + TMDO_TEST_DB_NAME: wp_wpdo_test + run: php vendor/bin/phpunit --configuration phpunit-integration.xml --testdox diff --git a/phpunit-integration.xml b/phpunit-integration.xml new file mode 100644 index 0000000..18956e2 --- /dev/null +++ b/phpunit-integration.xml @@ -0,0 +1,15 @@ + + + + + tests/integration + + + diff --git a/tests/integration/HivepressIntegrationTest.php b/tests/integration/HivepressIntegrationTest.php new file mode 100644 index 0000000..b1f6ce1 --- /dev/null +++ b/tests/integration/HivepressIntegrationTest.php @@ -0,0 +1,197 @@ +query( 'DROP TABLE IF EXISTS `' . self::TABLE_FAVORITE . '`' ); + $wpdb->query( "CREATE TABLE `" . self::TABLE_FAVORITE . "` ( + comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + listing_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME NOT NULL, + UNIQUE KEY unique_user_listing (user_id, listing_id), + KEY idx_listing (listing_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_MESSAGE . '`' ); + $wpdb->query( "CREATE TABLE `" . self::TABLE_MESSAGE . "` ( + comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + sender_id BIGINT UNSIGNED NOT NULL, + recipient_id BIGINT UNSIGNED NOT NULL, + listing_id BIGINT UNSIGNED NOT NULL, + is_read TINYINT(1) NOT NULL DEFAULT 0, + sent_at DATETIME NOT NULL, + KEY idx_recipient_unread (recipient_id, is_read), + KEY idx_thread (listing_id, sent_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_FAVORITE . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_MESSAGE . '`' ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE_FAVORITE . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE_MESSAGE . '`' ); + // Clear FSM module state between tests. + $GLOBALS['_wp_options']['wpdo_features'] = array(); + } + + // ── Schema tests ──────────────────────────────────────────────────────── + + public function test_favorite_table_has_unique_user_listing_constraint(): void { + global $wpdb; + $row = $wpdb->get_row( 'SHOW CREATE TABLE `' . self::TABLE_FAVORITE . '`', 'ARRAY_A' ); + $this->assertNotNull( $row ); + $ddl = $row['Create Table'] ?? ''; + $this->assertStringContainsString( 'UNIQUE KEY', $ddl ); + $this->assertStringContainsString( 'unique_user_listing', $ddl ); + } + + public function test_message_table_has_recipient_unread_index(): void { + global $wpdb; + $row = $wpdb->get_row( 'SHOW CREATE TABLE `' . self::TABLE_MESSAGE . '`', 'ARRAY_A' ); + $this->assertNotNull( $row ); + $ddl = $row['Create Table'] ?? ''; + $this->assertStringContainsString( 'idx_recipient_unread', $ddl ); + $this->assertStringContainsString( 'recipient_id', $ddl ); + } + + // ── mirror_insert real roundtrip ──────────────────────────────────────── + + public function test_favorite_mirror_insert_writes_real_row(): void { + global $wpdb; + $adapter = new WPDO_HivePress_Favorites_Adapter(); + + $comment = new stdClass(); + $comment->comment_type = 'hp_favorite'; + $comment->user_id = 11; + $comment->comment_post_ID = 222; + $comment->comment_date = '2026-05-04 12:00:00'; + + $adapter->mirror_insert( 1001, $comment ); + + $rows = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_FAVORITE . '`' ); + $this->assertSame( 1, $rows ); + + $saved = $wpdb->get_row( 'SELECT * FROM `' . self::TABLE_FAVORITE . '` WHERE comment_id = 1001', 'ARRAY_A' ); + $this->assertSame( '11', (string) $saved['user_id'] ); + $this->assertSame( '222', (string) $saved['listing_id'] ); + } + + public function test_favorite_unique_constraint_blocks_double_tap(): void { + global $wpdb; + $adapter = new WPDO_HivePress_Favorites_Adapter(); + + // Two different comment_ids but same user+listing — UNIQUE should win. + $comment = new stdClass(); + $comment->comment_type = 'hp_favorite'; + $comment->user_id = 7; + $comment->comment_post_ID = 99; + $comment->comment_date = '2026-05-04 12:00:00'; + + $adapter->mirror_insert( 2001, $comment ); + $adapter->mirror_insert( 2002, $comment ); // Race-style duplicate. + + $rows = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_FAVORITE . '` WHERE user_id = 7 AND listing_id = 99' ); + $this->assertSame( 1, $rows, 'UNIQUE(user_id, listing_id) must reject duplicate.' ); + } + + public function test_message_mirror_insert_promotes_recipient_id(): void { + global $wpdb; + $adapter = new WPDO_HivePress_Messages_Adapter(); + + $comment = new stdClass(); + $comment->comment_type = 'hp_message'; + $comment->user_id = 5; // sender + $comment->comment_karma = 42; // recipient (HP hack) + $comment->comment_post_ID = 99; + $comment->comment_approved = 0; + $comment->comment_date = '2026-05-04 12:00:00'; + + $adapter->mirror_insert( 3001, $comment ); + + $saved = $wpdb->get_row( 'SELECT * FROM `' . self::TABLE_MESSAGE . '` WHERE comment_id = 3001', 'ARRAY_A' ); + $this->assertNotNull( $saved ); + $this->assertSame( '42', (string) $saved['recipient_id'] ); + $this->assertSame( '5', (string) $saved['sender_id'] ); + + // recipient_unread index supports the dashboard query. + $unread_count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_MESSAGE . '` WHERE recipient_id = 42 AND is_read = 0' ); + $this->assertSame( 1, $unread_count ); + } + + // ── FSM lifecycle ─────────────────────────────────────────────────────── + + public function test_fsm_lifecycle_progression(): void { + // Replicates a typical migration: idle → dual_write → backfill → ... + // → cleanup → complete. Each step verified via Feature_Flags state. + WPDO_Feature_Flags::set( 'comment_hp_favorite', 'idle' ); + $this->assertSame( 'idle', WPDO_Feature_Flags::get( 'comment_hp_favorite' ) ); + $this->assertFalse( WPDO_Feature_Flags::is_query_active( 'comment_hp_favorite' ) ); + + foreach ( array( 'dual_write', 'backfill', 'verify' ) as $state ) { + WPDO_Feature_Flags::set( 'comment_hp_favorite', $state ); + // Reads still go to legacy until cutover. + $this->assertFalse( + WPDO_Feature_Flags::is_query_active( 'comment_hp_favorite' ), + "State $state should not be query-active yet" + ); + } + + WPDO_Feature_Flags::set( 'comment_hp_favorite', 'cutover' ); + $this->assertTrue( WPDO_Feature_Flags::is_query_active( 'comment_hp_favorite' ) ); + + WPDO_Feature_Flags::set( 'comment_hp_favorite', 'cleanup' ); + $this->assertTrue( WPDO_Feature_Flags::is_query_active( 'comment_hp_favorite' ) ); + + WPDO_Feature_Flags::set( 'comment_hp_favorite', 'complete' ); + $this->assertTrue( WPDO_Feature_Flags::is_query_active( 'comment_hp_favorite' ) ); + } + + public function test_cron_optimizer_respects_module_state(): void { + WPDO_HivePress_Cron_Optimizer::reset_for_tests(); + // Module idle → optimizer must not query. + WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' ); + $count = WPDO_HivePress_Cron_Optimizer::maybe_run(); + $this->assertSame( 0, $count, 'Optimizer must short-circuit when module is idle.' ); + } +} + +// Production adapters use `$wpdb->prefix . self::TABLE` to compose the full +// table name. Integration test bootstrap sets prefix to `wp_itest_`, so the +// production mirror_insert() naturally writes to wp_itest_wpdo_comment_hp_*. +// No subclassing or test-double needed — the production code path is exercised +// directly against real MariaDB with isolated prefix. diff --git a/tests/integration/HivepressTermCommentFieldsTest.php b/tests/integration/HivepressTermCommentFieldsTest.php new file mode 100644 index 0000000..71409af --- /dev/null +++ b/tests/integration/HivepressTermCommentFieldsTest.php @@ -0,0 +1,131 @@ +assertContains( 'hp_sort_order', $keys ); + $this->assertContains( 'hp_default', $keys ); + $this->assertContains( 'hp_icon', $keys ); + $this->assertCount( 3, $keys ); + } + + public function test_comment_hp_review_group_registered(): void { + $keys = WPDO_Entity_Registry::get_group_keys( 'comment', 'hp_review' ); + $this->assertContains( 'hp_rating', $keys ); + $this->assertCount( 1, $keys ); + } + + // ── is_managed contract ───────────────────────────────────────────────── + + public function test_term_hp_keys_are_managed(): void { + $this->assertTrue( WPDO_Entity_Registry::is_managed( 'term', 'hp_sort_order' ) ); + $this->assertTrue( WPDO_Entity_Registry::is_managed( 'term', 'hp_default' ) ); + $this->assertTrue( WPDO_Entity_Registry::is_managed( 'term', 'hp_icon' ) ); + } + + public function test_comment_hp_keys_are_managed(): void { + $this->assertTrue( WPDO_Entity_Registry::is_managed( 'comment', 'hp_rating' ) ); + } + + public function test_unregistered_term_keys_pass_through(): void { + $this->assertFalse( WPDO_Entity_Registry::is_managed( 'term', 'note_group' ) ); + $this->assertFalse( WPDO_Entity_Registry::is_managed( 'term', 'product_count_product_cat' ) ); + $this->assertFalse( WPDO_Entity_Registry::is_managed( 'term', '_wxr_import_user' ) ); + } + + public function test_unregistered_comment_keys_pass_through(): void { + $this->assertFalse( WPDO_Entity_Registry::is_managed( 'comment', 'note_group' ) ); + $this->assertFalse( WPDO_Entity_Registry::is_managed( 'comment', '_wxr_import_user' ) ); + } + + public function test_term_keys_not_treated_as_comment_keys(): void { + // hp_sort_order is term-only; comment side should not see it as managed. + $this->assertFalse( WPDO_Entity_Registry::is_managed( 'comment', 'hp_sort_order' ) ); + } + + public function test_comment_keys_not_treated_as_term_keys(): void { + // hp_rating is comment-only; term side should not see it as managed. + $this->assertFalse( WPDO_Entity_Registry::is_managed( 'term', 'hp_rating' ) ); + } + + // ── Field metadata contract ───────────────────────────────────────────── + + public function test_hp_sort_order_field_metadata(): void { + $field = WPDO_Entity_Registry::get_field( 'term', 'hp_sort_order' ); + $this->assertNotNull( $field ); + $this->assertSame( 'hp_sort_order', $field['key'] ); + $this->assertSame( 'integer', $field['type'] ); + $this->assertTrue( ! empty( $field['searchable'] ) ); + } + + public function test_hp_rating_field_metadata(): void { + $field = WPDO_Entity_Registry::get_field( 'comment', 'hp_rating' ); + $this->assertNotNull( $field ); + $this->assertSame( 'hp_rating', $field['key'] ); + $this->assertSame( 'integer', $field['type'] ); + $this->assertTrue( ! empty( $field['searchable'] ) ); + } + + public function test_hp_default_is_enum_with_options(): void { + $field = WPDO_Entity_Registry::get_field( 'term', 'hp_default' ); + $this->assertSame( 'enum', $field['type'] ); + $this->assertSame( array( '0', '1' ), $field['options'] ?? array() ); + } + + // ── get_field returns null for unregistered ───────────────────────────── + + public function test_get_field_returns_null_for_unregistered(): void { + $this->assertNull( WPDO_Entity_Registry::get_field( 'term', 'unregistered_key' ) ); + $this->assertNull( WPDO_Entity_Registry::get_field( 'comment', 'unregistered_key' ) ); + } + + // ── Bootstrap idempotency ─────────────────────────────────────────────── + + public function test_register_entity_fields_is_idempotent(): void { + // Re-running registration should not duplicate fields. + WPDO_Hivepress_Term_Comment_Fields::register_entity_fields(); + WPDO_Hivepress_Term_Comment_Fields::register_entity_fields(); + + $keys = WPDO_Entity_Registry::get_group_keys( 'term', 'hp_taxonomy' ); + $this->assertCount( 3, $keys, 'Re-registration must not duplicate keys' ); + } +} diff --git a/tests/integration/HivepressTransientFilterTest.php b/tests/integration/HivepressTransientFilterTest.php new file mode 100644 index 0000000..827d907 --- /dev/null +++ b/tests/integration/HivepressTransientFilterTest.php @@ -0,0 +1,252 @@ +query( + 'CREATE TABLE IF NOT EXISTS `' . self::POSTMETA . '` ( + meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) DEFAULT NULL, + meta_value longtext, + PRIMARY KEY (meta_id), + KEY post_id (post_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4' + ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); + + // Reset wp_options state for filter tests + $GLOBALS['_wp_options'] = array(); + + // Default the toggle to enabled + update_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED, '1' ); + } + + // ── is_target_key() ───────────────────────────────────────────────────── + + public function test_is_target_key_matches_hp_transient_value(): void { + $this->assertTrue( WPDO_Hivepress_Transient_Filter::is_target_key( '_transient_hp_models/listing_category/version' ) ); + } + + public function test_is_target_key_matches_hp_transient_timeout(): void { + $this->assertTrue( WPDO_Hivepress_Transient_Filter::is_target_key( '_transient_timeout_hp_models/term/listing_availability/version' ) ); + } + + public function test_is_target_key_rejects_non_hp_transient(): void { + $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( '_transient_other_plugin_cache' ) ); + $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( '_transient_timeout_other' ) ); + } + + public function test_is_target_key_rejects_regular_meta(): void { + $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( 'hp_price' ) ); + $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( '_thumbnail_id' ) ); + } + + public function test_is_target_key_rejects_non_string(): void { + $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( null ) ); + $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( 123 ) ); + $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( array() ) ); + } + + // ── translate_key() ───────────────────────────────────────────────────── + + public function test_translate_key_namespaces_by_post_id(): void { + $key1 = WPDO_Hivepress_Transient_Filter::translate_key( 100, '_transient_hp_models/cat/v1' ); + $key2 = WPDO_Hivepress_Transient_Filter::translate_key( 200, '_transient_hp_models/cat/v1' ); + + $this->assertNotSame( $key1, $key2, 'Same meta_key on different posts must yield different translated keys.' ); + $this->assertStringContainsString( 'wpdo_hp_pm_100_', $key1 ); + $this->assertStringContainsString( 'wpdo_hp_pm_200_', $key2 ); + } + + public function test_translate_key_strips_transient_prefix(): void { + $value_key = WPDO_Hivepress_Transient_Filter::translate_key( 100, '_transient_hp_models/cat' ); + $timeout_key = WPDO_Hivepress_Transient_Filter::translate_key( 100, '_transient_timeout_hp_models/cat' ); + + // Value and timeout SHOULD share the same translated suffix (md5 of stripped name) + // since they refer to the same logical cache entry. + $this->assertSame( $value_key, $timeout_key ); + } + + public function test_translate_key_md5_handles_long_input(): void { + $long_key = '_transient_hp_models/term/listing_availability/' . str_repeat( 'a', 200 ); + $translated = WPDO_Hivepress_Transient_Filter::translate_key( 1, $long_key ); + + // Result must fit within wp_options.option_name (172 chars used as proxy). + $option_name = '_transient_timeout_' . $translated; + $this->assertLessThanOrEqual( 191, strlen( $option_name ), 'Translated option_name must fit MySQL VARCHAR(191).' ); + } + + // ── on_read() callback ────────────────────────────────────────────────── + + public function test_on_read_returns_pre_for_non_target_key(): void { + $result = WPDO_Hivepress_Transient_Filter::on_read( null, 100, '_thumbnail_id', true ); + $this->assertNull( $result, 'Non-target keys must pass through (return $pre).' ); + } + + public function test_on_read_returns_value_from_wp_options_when_present(): void { + // Seed a "translated" wp_options entry as if filter previously wrote it. + $key = '_transient_hp_models/listing_category/version'; + $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); + update_option( '_transient_' . $translated, 'cached-value-xyz' ); + + $result = WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ); + $this->assertSame( array( 'cached-value-xyz' ), $result ); + } + + public function test_on_read_returns_pre_on_cache_miss(): void { + // No wp_options entry seeded. + $result = WPDO_Hivepress_Transient_Filter::on_read( null, 100, '_transient_hp_models/cat/v1', true ); + $this->assertNull( $result, 'Cache miss should fall through (return $pre = null).' ); + } + + // ── on_add() / on_update() callbacks ──────────────────────────────────── + + public function test_on_add_writes_to_wp_options_and_short_circuits(): void { + $key = '_transient_hp_models/cat/v1'; + $result = WPDO_Hivepress_Transient_Filter::on_add( null, 100, $key, 'value-xyz', false ); + + $this->assertTrue( $result, 'Filter must short-circuit (return truthy).' ); + + $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); + $this->assertSame( 'value-xyz', get_option( '_transient_' . $translated ) ); + } + + public function test_on_update_writes_to_wp_options(): void { + $key = '_transient_hp_models/cat/v1'; + WPDO_Hivepress_Transient_Filter::on_update( null, 200, $key, 'updated-value', '' ); + + $translated = WPDO_Hivepress_Transient_Filter::translate_key( 200, $key ); + $this->assertSame( 'updated-value', get_option( '_transient_' . $translated ) ); + } + + public function test_on_update_handles_timeout_separately(): void { + $value_key = '_transient_hp_models/cat/v1'; + $timeout_key = '_transient_timeout_hp_models/cat/v1'; + + WPDO_Hivepress_Transient_Filter::on_update( null, 100, $value_key, 'val', '' ); + WPDO_Hivepress_Transient_Filter::on_update( null, 100, $timeout_key, '99999999', '' ); + + $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $value_key ); + $this->assertSame( 'val', get_option( '_transient_' . $translated ) ); + $this->assertSame( '99999999', get_option( '_transient_timeout_' . $translated ) ); + } + + public function test_on_add_passes_through_non_target_keys(): void { + $result = WPDO_Hivepress_Transient_Filter::on_add( null, 100, '_thumbnail_id', '50', false ); + $this->assertNull( $result ); + // And nothing was written to wp_options + $this->assertFalse( get_option( '_transient_wpdo_hp_pm_100_' . md5( '' ) ) ); + } + + // ── on_delete() callback ──────────────────────────────────────────────── + + public function test_on_delete_clears_wp_options_entry(): void { + $key = '_transient_hp_models/cat/v1'; + WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'value', '' ); + $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); + $this->assertSame( 'value', get_option( '_transient_' . $translated ) ); + + WPDO_Hivepress_Transient_Filter::on_delete( null, 100, $key, '', false ); + $this->assertFalse( get_option( '_transient_' . $translated ) ); + } + + public function test_on_delete_passes_through_non_target_keys(): void { + $result = WPDO_Hivepress_Transient_Filter::on_delete( null, 100, 'hp_price', '', false ); + $this->assertNull( $result ); + } + + // ── Round-trip: write then read ───────────────────────────────────────── + + public function test_full_round_trip_write_then_read(): void { + $key = '_transient_hp_models/listing_category/abc123'; + WPDO_Hivepress_Transient_Filter::on_update( null, 500, $key, array( 'serialized', 'data' ), '' ); + + $value = WPDO_Hivepress_Transient_Filter::on_read( null, 500, $key, true ); + $this->assertSame( array( array( 'serialized', 'data' ) ), $value ); + } + + // ── count/purge legacy ────────────────────────────────────────────────── + + public function test_count_legacy_postmeta_rows_returns_zero_when_clean(): void { + $this->assertSame( 0, WPDO_Hivepress_Transient_Filter::count_legacy_postmeta_rows() ); + } + + public function test_count_legacy_postmeta_rows_counts_hp_transients_only(): void { + global $wpdb; + // Insert a mix of rows: target hp transients + non-target rows. + $rows = array( + array( 1, '_transient_hp_models/cat/v1', 'val1' ), + array( 1, '_transient_timeout_hp_models/cat/v1', '99999' ), + array( 2, '_transient_hp_models/tag/v2', 'val2' ), + array( 1, 'hp_price', '100' ), + array( 1, '_thumbnail_id', '50' ), + array( 2, '_transient_other_plugin', 'foreign' ), // non-hp transient + ); + foreach ( $rows as $r ) { + $wpdb->insert( self::POSTMETA, array( + 'post_id' => $r[0], + 'meta_key' => $r[1], + 'meta_value' => $r[2], + ) ); + } + + $count = WPDO_Hivepress_Transient_Filter::count_legacy_postmeta_rows(); + $this->assertSame( 3, $count, 'Must count only _transient_hp_* rows, not other-plugin transients or normal meta.' ); + } + + public function test_purge_legacy_postmeta_rows_deletes_only_hp_transients(): void { + global $wpdb; + $wpdb->insert( self::POSTMETA, array( 'post_id' => 1, 'meta_key' => '_transient_hp_models/cat/v1', 'meta_value' => 'a' ) ); + $wpdb->insert( self::POSTMETA, array( 'post_id' => 1, 'meta_key' => '_transient_timeout_hp_models/cat/v1', 'meta_value' => '99' ) ); + $wpdb->insert( self::POSTMETA, array( 'post_id' => 1, 'meta_key' => 'hp_price', 'meta_value' => '50' ) ); + $wpdb->insert( self::POSTMETA, array( 'post_id' => 2, 'meta_key' => '_transient_woo_thing', 'meta_value' => 'foreign' ) ); + + $deleted = WPDO_Hivepress_Transient_Filter::purge_legacy_postmeta_rows(); + $this->assertSame( 2, $deleted ); + + $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::POSTMETA . '`' ); + $this->assertSame( 2, $remaining, 'hp_price + _transient_woo_thing must remain.' ); + } + + // ── is_enabled() toggle ───────────────────────────────────────────────── + + public function test_is_enabled_defaults_true(): void { + delete_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED ); + $this->assertTrue( WPDO_Hivepress_Transient_Filter::is_enabled() ); + } + + public function test_is_enabled_respects_zero_value(): void { + update_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED, '0' ); + $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_enabled() ); + } +} diff --git a/tests/integration/ListingMetaInterceptorTest.php b/tests/integration/ListingMetaInterceptorTest.php new file mode 100644 index 0000000..6f0a04a --- /dev/null +++ b/tests/integration/ListingMetaInterceptorTest.php @@ -0,0 +1,160 @@ +query( "DROP TABLE IF EXISTS `{$wpdb->prefix}hpct_listing_meta`" ); + + // Simulate HPCT_Core being present — register_hooks() only logs the skip + // when HPCT is loaded but its table is missing (genuine inconsistency). + if ( ! class_exists( 'HPCT_Core' ) ) { + // phpcs:ignore Generic.Commenting.InlineComment.InvalidEndChar -- runtime stub + eval( 'class HPCT_Core {}' ); // @phpcs:ignore + } + + // Ensure wpdo_errors exists for the skip log; harmless if pre-created. + $wpdb->query( + "CREATE TABLE IF NOT EXISTS `{$wpdb->prefix}wpdo_errors` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + module varchar(50) NOT NULL DEFAULT '', + zone varchar(10) NOT NULL DEFAULT '', + hook varchar(255) NOT NULL DEFAULT '', + message longtext NOT NULL, + context longtext, + created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + KEY idx_module (module) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + // Snapshot pre-call count of skip messages. + $pre = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_errors` WHERE module = %s AND hook = %s", + 'listing_meta', + 'register_hooks' + ) + ); + + $interceptor = new WPDO_Listing_Meta_Interceptor(); + $interceptor->register_hooks(); + + $post = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_errors` WHERE module = %s AND hook = %s", + 'listing_meta', + 'register_hooks' + ) + ); + $this->assertSame( $pre + 1, $post, 'Expected exactly one new skip message after register_hooks()' ); + } + + /** + * P-C1: WPDO_DB::upsert() must produce an INSERT ... ON DUPLICATE KEY UPDATE + * statement (single round-trip), and writes must succeed via the composite + * unique key (listing_id, meta_key). + * + * @runInSeparateProcess + */ + public function test_upsert_uses_composite_unique_key(): void { + global $wpdb; + + $table = $wpdb->prefix . 'hpct_listing_meta'; + + // Clean slate. + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + $wpdb->query( + "CREATE TABLE `{$table}` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + listing_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) NOT NULL DEFAULT '', + meta_value longtext, + PRIMARY KEY (id), + UNIQUE KEY ui_listing_meta (listing_id, meta_key(191)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + // First upsert: INSERT. + $result1 = WPDO_DB::upsert( + $table, + array( + 'listing_id' => 4242, + 'meta_key' => 'hp_price', + 'meta_value' => '100.00', + ), + array( 'meta_value' ), + array( 'listing_id', 'meta_key' ), + array( '%d', '%s', '%s' ) + ); + $this->assertNotFalse( $result1 ); + + // Second upsert: UPDATE via the composite key. + $result2 = WPDO_DB::upsert( + $table, + array( + 'listing_id' => 4242, + 'meta_key' => 'hp_price', + 'meta_value' => '250.50', + ), + array( 'meta_value' ), + array( 'listing_id', 'meta_key' ), + array( '%d', '%s', '%s' ) + ); + $this->assertNotFalse( $result2 ); + + // Verify only one row exists for (4242, 'hp_price') with the latest value. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT meta_value FROM `{$table}` WHERE listing_id = %d AND meta_key = %s", + 4242, + 'hp_price' + ), + ARRAY_A + ); + $this->assertCount( 1, $rows, 'Composite UNIQUE must collapse two upserts into a single row' ); + $this->assertSame( '250.50', $rows[0]['meta_value'] ); + + // Cleanup. + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + + /** + * Verify table_exists() returns false when table missing, true after CREATE. + * + * @runInSeparateProcess + */ + public function test_table_exists_detection(): void { + global $wpdb; + + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}hpct_listing_meta`" ); + + // Use reflection to invoke private static. + $ref = new ReflectionClass( WPDO_Listing_Meta_Interceptor::class ); + $method = $ref->getMethod( 'table_exists' ); + $method->setAccessible( true ); + + $this->assertFalse( $method->invoke( null ), 'table_exists must return false when table missing' ); + } +} diff --git a/tests/integration/TermCommentBackfillTest.php b/tests/integration/TermCommentBackfillTest.php new file mode 100644 index 0000000..76d7103 --- /dev/null +++ b/tests/integration/TermCommentBackfillTest.php @@ -0,0 +1,247 @@ +terms = self::TERMS; + $wpdb->termmeta = self::TERMMETA; + $wpdb->comments = self::COMMENTS; + $wpdb->commentmeta = self::COMMENTMETA; + + WPDO_Entity_Registry::register_adapter( 'term', new WPDO_Adapter_Term() ); + WPDO_Entity_Registry::register_adapter( 'comment', new WPDO_Adapter_Comment() ); + WPDO_Hivepress_Term_Comment_Fields::register_entity_fields(); + + // Test tables. + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMS . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::TERMS . '` ( + term_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(200) NOT NULL DEFAULT "", + PRIMARY KEY (term_id) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTS . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::COMMENTS . '` ( + comment_ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + comment_post_ID bigint(20) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (comment_ID) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::TERMMETA . '` ( + meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + term_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) DEFAULT NULL, + meta_value longtext, + PRIMARY KEY (meta_id), + KEY term_id (term_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4' ); + + $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::COMMENTMETA . '` ( + meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + comment_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) DEFAULT NULL, + meta_value longtext, + PRIMARY KEY (meta_id), + KEY comment_id (comment_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4' ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_FLAT . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::TERM_FLAT . '` ( + term_id bigint(20) unsigned NOT NULL, + hp_sort_order int(11) DEFAULT NULL, + hp_default tinyint(1) DEFAULT NULL, + hp_icon varchar(64) DEFAULT NULL, + PRIMARY KEY (term_id) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENT_FLAT . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::COMMENT_FLAT . '` ( + comment_id bigint(20) unsigned NOT NULL, + hp_rating tinyint(1) DEFAULT NULL, + PRIMARY KEY (comment_id) + ) DEFAULT CHARACTER SET utf8mb4' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + foreach ( array( self::TERMS, self::COMMENTS, self::TERM_FLAT, self::COMMENT_FLAT ) as $tbl ) { + $wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' ); + } + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::TERMS . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTS . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::TERMMETA . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTMETA . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::TERM_FLAT . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENT_FLAT . '`' ); + } + + // ── backfill_group ────────────────────────────────────────────────────── + + public function test_backfill_returns_zero_counts_for_empty_db(): void { + $result = WPDO_Term_Comment_Backfill::backfill_group( 'term', 'hp_taxonomy', false ); + $this->assertSame( 0, $result['candidates'] ); + $this->assertSame( 0, $result['written'] ); + } + + public function test_dry_run_does_not_write(): void { + global $wpdb; + $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) ); + + $result = WPDO_Term_Comment_Backfill::backfill_group( 'term', 'hp_taxonomy', true ); + $this->assertSame( 1, $result['candidates'] ); + $this->assertSame( 0, $result['written'] ); + $this->assertTrue( $result['dry_run'] ); + + $flat_count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERM_FLAT . '`' ); + $this->assertSame( 0, $flat_count, 'dry_run must not INSERT' ); + } + + public function test_backfill_pivots_multi_key_into_single_row(): void { + global $wpdb; + $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_default', 'meta_value' => '1' ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_icon', 'meta_value' => 'fa-star' ) ); + + $result = WPDO_Term_Comment_Backfill::backfill_group( 'term', 'hp_taxonomy', false ); + $this->assertSame( 1, $result['candidates'] ); + + $row = $wpdb->get_row( 'SELECT * FROM `' . self::TERM_FLAT . '` WHERE term_id = 1', ARRAY_A ); + $this->assertNotNull( $row ); + $this->assertSame( '5', (string) $row['hp_sort_order'] ); + $this->assertSame( '1', (string) $row['hp_default'] ); + $this->assertSame( 'fa-star', $row['hp_icon'] ); + } + + public function test_backfill_handles_multiple_terms(): void { + global $wpdb; + for ( $i = 1; $i <= 5; $i++ ) { + $wpdb->insert( self::TERMS, array( 'term_id' => $i, 'name' => 'term_' . $i ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => $i, 'meta_key' => 'hp_sort_order', 'meta_value' => $i * 10 ) ); + } + + $result = WPDO_Term_Comment_Backfill::backfill_group( 'term', 'hp_taxonomy', false ); + $this->assertSame( 5, $result['candidates'] ); + + $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERM_FLAT . '`' ); + $this->assertSame( 5, $count ); + } + + public function test_backfill_idempotent_on_re_run(): void { + global $wpdb; + $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) ); + + WPDO_Term_Comment_Backfill::backfill_group( 'term', 'hp_taxonomy', false ); + WPDO_Term_Comment_Backfill::backfill_group( 'term', 'hp_taxonomy', false ); + + $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERM_FLAT . '`' ); + $this->assertSame( 1, $count, 'Re-running must not duplicate (PRIMARY KEY enforces)' ); + } + + public function test_backfill_updates_existing_row_via_on_duplicate_key(): void { + global $wpdb; + $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) ); + $wpdb->insert( self::TERM_FLAT, array( 'term_id' => 1, 'hp_sort_order' => 99 ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) ); + + WPDO_Term_Comment_Backfill::backfill_group( 'term', 'hp_taxonomy', false ); + + $value = $wpdb->get_var( 'SELECT hp_sort_order FROM `' . self::TERM_FLAT . '` WHERE term_id = 1' ); + $this->assertSame( '5', (string) $value, 'wp_termmeta value should overwrite stale flat value' ); + } + + public function test_backfill_handles_comment_entity(): void { + global $wpdb; + $wpdb->insert( self::COMMENTS, array( 'comment_ID' => 1, 'comment_post_ID' => 100 ) ); + $wpdb->insert( self::COMMENTMETA, array( 'comment_id' => 1, 'meta_key' => 'hp_rating', 'meta_value' => '5' ) ); + + $result = WPDO_Term_Comment_Backfill::backfill_group( 'comment', 'hp_review', false ); + $this->assertSame( 1, $result['candidates'] ); + + $value = $wpdb->get_var( 'SELECT hp_rating FROM `' . self::COMMENT_FLAT . '` WHERE comment_id = 1' ); + $this->assertSame( '5', (string) $value ); + } + + public function test_backfill_only_picks_registered_keys(): void { + global $wpdb; + $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'unknown_key', 'meta_value' => 'x' ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'note_group', 'meta_value' => 'foo' ) ); + + WPDO_Term_Comment_Backfill::backfill_group( 'term', 'hp_taxonomy', false ); + + // hp_icon and hp_default should be NULL (not in wp_termmeta). + $row = $wpdb->get_row( 'SELECT * FROM `' . self::TERM_FLAT . '` WHERE term_id = 1', ARRAY_A ); + $this->assertSame( '5', (string) $row['hp_sort_order'] ); + $this->assertNull( $row['hp_default'] ); + $this->assertNull( $row['hp_icon'] ); + } + + public function test_backfill_unknown_group_returns_error(): void { + $result = WPDO_Term_Comment_Backfill::backfill_group( 'term', 'bogus_group', false ); + $this->assertArrayHasKey( 'error', $result ); + $this->assertSame( 0, $result['candidates'] ); + } + + public function test_backfill_unsupported_entity_returns_error(): void { + $result = WPDO_Term_Comment_Backfill::backfill_group( 'user', 'hp_taxonomy', false ); + $this->assertArrayHasKey( 'error', $result ); + } + + // ── backfill_all ──────────────────────────────────────────────────────── + + public function test_backfill_all_runs_every_group(): void { + global $wpdb; + $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) ); + $wpdb->insert( self::COMMENTS, array( 'comment_ID' => 1, 'comment_post_ID' => 100 ) ); + $wpdb->insert( self::COMMENTMETA, array( 'comment_id' => 1, 'meta_key' => 'hp_rating', 'meta_value' => '5' ) ); + + $results = WPDO_Term_Comment_Backfill::backfill_all( false ); + $this->assertCount( 2, $results ); + $this->assertArrayHasKey( 'hp_taxonomy', $results ); + $this->assertArrayHasKey( 'hp_review', $results ); + $this->assertSame( 1, $results['hp_taxonomy']['candidates'] ); + $this->assertSame( 1, $results['hp_review']['candidates'] ); + } +} diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php new file mode 100644 index 0000000..2ce8c97 --- /dev/null +++ b/tests/integration/bootstrap.php @@ -0,0 +1,98 @@ +