Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f587c39dc | |||
| c5027b0fce | |||
| e33ae4e626 |
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: 2meet Data Optimizer
|
* Plugin Name: 2meet Data Optimizer
|
||||||
* Plugin URI: https://2meet.io/2meet-data-optimizer
|
* Plugin URI: https://2meet.io/2meet-data-optimizer
|
||||||
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
|
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
|
||||||
* Version: 1.0.0
|
* Version: 1.0.1
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Tested up to: 6.9.4
|
* Tested up to: 6.9.4
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
define( 'TMDO_VERSION', '1.0.0' );
|
define( 'TMDO_VERSION', '1.0.1' );
|
||||||
define( 'TMDO_DB_VERSION', '2.1.0' );
|
define( 'TMDO_DB_VERSION', '2.1.0' );
|
||||||
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|||||||
+53
-1
@@ -7,6 +7,38 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.0.1] — 2026-08-08 — Schema Registry 冪等性修正(cold zone 欄位重複累積)
|
||||||
|
|
||||||
|
**性質**:核心 bug 修復。無 schema 變更,無 API 變更,無破壞性變更。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`TMDO_Schema_Registry::register()` 的 cold zone 分支會無限累積重複項**
|
||||||
|
(`includes/class-tmdo-schema-registry.php`)
|
||||||
|
|
||||||
|
三個 zone 索引的寫入方式並不一致:
|
||||||
|
- `hot` → `$this->hot_columns[$post_type][$column] = ...`(以 column 為 key,天生冪等)
|
||||||
|
- `warm` → `$this->warm_fields[$meta_key] = ...`(以 meta_key 為 key,天生冪等)
|
||||||
|
- `cold` → `$this->cold_fields[$post_type][] = ...`(**append,無去重**)
|
||||||
|
|
||||||
|
而 `wpdo_register_fields` 本來就會被觸發多次——核心自身在 `plugins_loaded:4` 觸發,
|
||||||
|
主檔另有 late-bind safety net 於 `plugins_loaded:30` 再次觸發,好讓在 `:6` 之後才
|
||||||
|
bootstrap 的 AddOn 也能趕上;再加上主外掛與其 AddOn 可能同時註冊同一批欄位。
|
||||||
|
|
||||||
|
**實測影響**:某站台 `cold_fields['hp_vendor']` 累積到 **35 筆但只有 9 個相異值**,
|
||||||
|
每個 key 重複 4 次。修正後為 9 筆、零重複。
|
||||||
|
|
||||||
|
修法:cold 分支加入 `in_array( ..., true )` 去重,使 `register()` 對同一
|
||||||
|
`(post_type, meta_key)` 成為冪等操作,與 hot / warm 的既有行為一致。
|
||||||
|
|
||||||
|
### 驗證
|
||||||
|
|
||||||
|
- 核心單元測試 **587 tests / 1156 assertions** 全綠
|
||||||
|
- `wp wpdo doctor` 於兩個站台皆 0 FAIL
|
||||||
|
- Version bump 1.0.0 → 1.0.1(2 點一致:header / `TMDO_VERSION`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.0.0] — 2026-07-31 — 取代 wp-data-optimizer v3.4.6
|
## [1.0.0] — 2026-07-31 — 取代 wp-data-optimizer v3.4.6
|
||||||
|
|
||||||
本版把 `wp-data-optimizer` v3.0.1–v3.4.6(92 個 commit)全數 backport 進來,
|
本版把 `wp-data-optimizer` v3.0.1–v3.4.6(92 個 commit)全數 backport 進來,
|
||||||
@@ -49,12 +81,32 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|||||||
- 選單 slug 統一回 `wp-data-optimizer`(B 內部 10+ 處連結本來就指向它)
|
- 選單 slug 統一回 `wp-data-optimizer`(B 內部 10+ 處連結本來就指向它)
|
||||||
- HP adapter 介面改為 `TMDO_ extends WPDO_`,讓 `instanceof WPDO_HivePress_Adapter` 成立
|
- HP adapter 介面改為 `TMDO_ extends WPDO_`,讓 `instanceof WPDO_HivePress_Adapter` 成立
|
||||||
|
|
||||||
|
### 🖥 Admin
|
||||||
|
|
||||||
|
- Settings 七個分區各加「儲存此區塊」AJAX 存檔(`wp_ajax_wpdo_save_settings_section`
|
||||||
|
+ 7 個 `save_section_*()` + `admin/assets/wpdo-settings.js`),整頁送出仍可用
|
||||||
|
- inline `style=` 由 348 處降到 9 處(剩下的全是 `--wpdo-*` CSS custom property
|
||||||
|
載體);`wpdo-admin.css` 543 → 912 行,補 149 個 class
|
||||||
|
|
||||||
|
### 🚧 邊界
|
||||||
|
|
||||||
|
- 核心不再無守衛呼叫 AddOn 類別。`TMDO_Listing_Stats`(HivePress AddOn)先前
|
||||||
|
在 admin Dashboard、兩個公開 REST 端點、`wp tmdo cleanup --archive-expired`
|
||||||
|
被直呼,沒裝該 AddOn 的站台會 fatal;`TMDO_HPCT_Import` 在 admin 與
|
||||||
|
`wp tmdo import-hpct` 同樣缺守衛
|
||||||
|
- `TMDO_Zone_Warm::VIEW_KEY` / `VIEW_TTL` 由核心持有(值與 AddOn 常數相同的
|
||||||
|
`'wpdo_views'`,指向同一批列,無資料遷移)
|
||||||
|
- 新增 `CoreBoundaryTest`:靜態掃描 126 個核心生產檔,強制每處 AddOn 類別
|
||||||
|
呼叫都在同一函式內有 `class_exists()` 守衛
|
||||||
|
|
||||||
### ✅ 品質
|
### ✅ 品質
|
||||||
|
|
||||||
- 128 個生產檔 `declare(strict_types=1)`
|
- 128 個生產檔 `declare(strict_types=1)`
|
||||||
- PHPCS 0 errors / PHPStan level 6(baseline 710)
|
- PHPCS 0 errors / PHPStan level 6(baseline 710)
|
||||||
- 測試:核心 unit 451 / integration 416、hivepress-addon 145、woocommerce-addon 10
|
- 測試:核心 unit 587 / integration 416、hivepress-addon unit 154 /
|
||||||
|
integration 57、woocommerce-addon unit 10 / integration 18
|
||||||
- gitea CI:核心 6 個 job 全綠 + 11 個 AddOn workflow + branch protection
|
- gitea CI:核心 6 個 job 全綠 + 11 個 AddOn workflow + branch protection
|
||||||
|
- 打包 10 項終檢全 PASS(569 KB / 187 entries,schema drift 0:34 CREATE / 34 DROP)
|
||||||
|
|
||||||
### ⚠️ ABI / 行為變更
|
### ⚠️ ABI / 行為變更
|
||||||
|
|
||||||
|
|||||||
@@ -2721,6 +2721,14 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
|||||||
* HPCT Import tab: preview and execute import.
|
* HPCT Import tab: preview and execute import.
|
||||||
*/
|
*/
|
||||||
private static function render_hpct_import(): void {
|
private static function render_hpct_import(): void {
|
||||||
|
// HPCT is a HivePress-family plugin; the importer ships in that AddOn.
|
||||||
|
if ( ! class_exists( 'TMDO_HPCT_Import' ) ) {
|
||||||
|
echo '<div class="notice notice-warning"><p>'
|
||||||
|
. esc_html__( 'HPCT 匯入需要啟用 2meet-data-optimizer-hivepress-addon。', '2meet-data-optimizer' )
|
||||||
|
. '</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle import action.
|
// Handle import action.
|
||||||
if ( isset( $_POST['wpdo_run_import'] ) && check_admin_referer( 'wpdo_hpct_import' ) ) {
|
if ( isset( $_POST['wpdo_run_import'] ) && check_admin_referer( 'wpdo_hpct_import' ) ) {
|
||||||
$result = TMDO_HPCT_Import::run();
|
$result = TMDO_HPCT_Import::run();
|
||||||
|
|||||||
@@ -648,6 +648,11 @@ class TMDO_CLI {
|
|||||||
* @subcommand import-hpct
|
* @subcommand import-hpct
|
||||||
*/
|
*/
|
||||||
public function import_hpct( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
|
public function import_hpct( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
|
||||||
|
// HPCT is a HivePress-family plugin; the importer ships in that AddOn.
|
||||||
|
if ( ! class_exists( 'TMDO_HPCT_Import' ) ) {
|
||||||
|
WP_CLI::error( 'import-hpct needs 2meet-data-optimizer-hivepress-addon to be active.' );
|
||||||
|
}
|
||||||
|
|
||||||
if ( TMDO_HPCT_Import::is_imported() ) {
|
if ( TMDO_HPCT_Import::is_imported() ) {
|
||||||
WP_CLI::warning( 'HPCT settings have already been imported.' );
|
WP_CLI::warning( 'HPCT settings have already been imported.' );
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -145,7 +145,16 @@ class TMDO_Schema_Registry {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'cold':
|
case 'cold':
|
||||||
$this->cold_fields[ $config['post_type'] ][] = $config['meta_key'];
|
// 去重:hot 以 column 為 key、warm 以 meta_key 為 key,兩者天生冪等;
|
||||||
|
// 唯獨 cold 是 append,重複註冊會無限膨脹。而 `wpdo_register_fields`
|
||||||
|
// 本來就會被 fire 多次(core plugins_loaded:4 + late-bind safety net :30),
|
||||||
|
// 加上主外掛與 AddOn 可能同時註冊同一批欄位,實測曾出現同一 key 重複 4 次。
|
||||||
|
if ( ! isset( $this->cold_fields[ $config['post_type'] ] ) ) {
|
||||||
|
$this->cold_fields[ $config['post_type'] ] = array();
|
||||||
|
}
|
||||||
|
if ( ! in_array( $config['meta_key'], $this->cold_fields[ $config['post_type'] ], true ) ) {
|
||||||
|
$this->cold_fields[ $config['post_type'] ][] = $config['meta_key'];
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -253,6 +253,15 @@ if ( ! function_exists( 'update_post_meta' ) ) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ( ! function_exists( 'add_post_meta' ) ) {
|
||||||
|
function add_post_meta( int $post_id, string $key, $value, bool $unique = false ): int|bool {
|
||||||
|
if ( $unique && isset( $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$GLOBALS['_wp_postmeta'][ $post_id ][ $key ] = $value;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
if ( ! function_exists( 'get_user_meta' ) ) {
|
if ( ! function_exists( 'get_user_meta' ) ) {
|
||||||
function get_user_meta( int $uid, string $key = '', bool $single = false ) {
|
function get_user_meta( int $uid, string $key = '', bool $single = false ) {
|
||||||
return $GLOBALS['_wp_usermeta'][ $uid ][ $key ] ?? ( $single ? '' : [] );
|
return $GLOBALS['_wp_usermeta'][ $uid ][ $key ] ?? ( $single ? '' : [] );
|
||||||
@@ -661,7 +670,9 @@ add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
|
|||||||
|
|
||||||
// TMDO_Listing_Stats → 2meet-data-optimizer-hivepress-addon.
|
// TMDO_Listing_Stats → 2meet-data-optimizer-hivepress-addon.
|
||||||
// Tests that hit REST endpoints using listing stats receive stub responses.
|
// Tests that hit REST endpoints using listing stats receive stub responses.
|
||||||
if ( ! class_exists( 'TMDO_Listing_Stats' ) ) {
|
// The HivePress AddOn's own suite defines TMDO_TEST_SKIP_LISTING_STATS_STUB so
|
||||||
|
// it can load the real class instead of this stub.
|
||||||
|
if ( ! defined( 'TMDO_TEST_SKIP_LISTING_STATS_STUB' ) && ! class_exists( 'TMDO_Listing_Stats' ) ) {
|
||||||
class TMDO_Listing_Stats {
|
class TMDO_Listing_Stats {
|
||||||
public static function register(): void {}
|
public static function register(): void {}
|
||||||
public static function get_view_count( int $post_id ): int { return 0; }
|
public static function get_view_count( int $post_id ): int { return 0; }
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Architectural guard: core must never hard-depend on an AddOn's classes.
|
||||||
|
*
|
||||||
|
* The 11 AddOns are optional. A site can run this plugin alone, so every
|
||||||
|
* reference core makes to an AddOn-owned class has to sit behind a
|
||||||
|
* class_exists() check — otherwise that code path fatals.
|
||||||
|
*
|
||||||
|
* This caught a real defect: the admin Dashboard tab and two public REST
|
||||||
|
* endpoints called TMDO_Listing_Stats (which ships in the HivePress AddOn)
|
||||||
|
* unconditionally, so they died with "Class not found" on any plain install.
|
||||||
|
*
|
||||||
|
* Static analysis on purpose — the alternative, booting core once per AddOn
|
||||||
|
* combination, is far more machinery for a weaker signal.
|
||||||
|
*/
|
||||||
|
class CoreBoundaryTest extends TestCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classes that are declared by an AddOn, never by core.
|
||||||
|
*
|
||||||
|
* Kept as prefixes so a newly added TMDO_HivePress_* adapter is covered
|
||||||
|
* without touching this list.
|
||||||
|
*
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
private const ADDON_CLASS_PREFIXES = array(
|
||||||
|
'TMDO_HivePress',
|
||||||
|
'TMDO_Hivepress',
|
||||||
|
'TMDO_HP_',
|
||||||
|
'TMDO_HPCT_',
|
||||||
|
'TMDO_Listing_',
|
||||||
|
'TMDO_Favorites_',
|
||||||
|
'TMDO_Messages_',
|
||||||
|
'TMDO_Memberships_',
|
||||||
|
'TMDO_Requests_',
|
||||||
|
'TMDO_Reviews_',
|
||||||
|
'TMDO_Statistics_',
|
||||||
|
'TMDO_WooCommerce',
|
||||||
|
'TMDO_Woocommerce',
|
||||||
|
'TMDO_WC_',
|
||||||
|
'TMDO_LatePoint',
|
||||||
|
'TMDO_Latepoint',
|
||||||
|
'TMDO_Infocards',
|
||||||
|
'TMDO_Bookings',
|
||||||
|
'TMDO_Collab',
|
||||||
|
'TMDO_Playlist',
|
||||||
|
'TMDO_Quotation',
|
||||||
|
'TMDO_Mobile_Bridge',
|
||||||
|
'TMDO_Hub_',
|
||||||
|
'TMDO_Spoke_',
|
||||||
|
'TMDO_Admin_HivePress',
|
||||||
|
'TMDO_Admin_WC',
|
||||||
|
'TMDO_CLI_HivePress',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every core production file, excluding vendor and the test suite itself.
|
||||||
|
*
|
||||||
|
* @return array<string, array{string}>
|
||||||
|
*/
|
||||||
|
public static function coreFileProvider(): array {
|
||||||
|
$root = dirname( __DIR__, 2 );
|
||||||
|
$files = array();
|
||||||
|
foreach ( array( 'includes', 'admin', 'cli', 'modules' ) as $dir ) {
|
||||||
|
$iterator = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator( $root . '/' . $dir, FilesystemIterator::SKIP_DOTS )
|
||||||
|
);
|
||||||
|
foreach ( $iterator as $file ) {
|
||||||
|
if ( 'php' === $file->getExtension() ) {
|
||||||
|
$rel = substr( $file->getPathname(), strlen( $root ) + 1 );
|
||||||
|
$files[ $rel ] = array( $file->getPathname() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort( $files );
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider coreFileProvider
|
||||||
|
*
|
||||||
|
* @param string $path Absolute path to a core production file.
|
||||||
|
*/
|
||||||
|
public function test_addon_classes_are_only_used_behind_a_guard( string $path ): void {
|
||||||
|
$source = file_get_contents( $path );
|
||||||
|
$this->assertIsString( $source, "Unreadable: {$path}" );
|
||||||
|
|
||||||
|
$lines = explode( "\n", $source );
|
||||||
|
$offences = array();
|
||||||
|
|
||||||
|
foreach ( $lines as $i => $line ) {
|
||||||
|
$code = $this->strip_comments_and_strings( $line );
|
||||||
|
foreach ( self::ADDON_CLASS_PREFIXES as $prefix ) {
|
||||||
|
// Only static calls / constant reads bind at runtime; a bare
|
||||||
|
// mention (e.g. inside an array of names to probe) does not.
|
||||||
|
if ( ! preg_match( '/\b(' . preg_quote( $prefix, '/' ) . '\w*)::/', $code, $m ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( $this->is_guarded( $lines, $i, $m[1] ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$offences[] = sprintf( '%s:%d — %s', basename( $path ), $i + 1, trim( $line ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
array(),
|
||||||
|
$offences,
|
||||||
|
"Core calls an AddOn class without a class_exists() guard:\n" . implode( "\n", $offences )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove line comments and string literals so mentions inside them are ignored.
|
||||||
|
*/
|
||||||
|
private function strip_comments_and_strings( string $line ): string {
|
||||||
|
$line = preg_replace( '#(//|\*|\#).*$#', '', $line ) ?? $line;
|
||||||
|
$line = preg_replace( "/'[^']*'/", "''", $line ) ?? $line;
|
||||||
|
return preg_replace( '/"[^"]*"/', '""', $line ) ?? $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A guard counts if class_exists() for the same class appears earlier in
|
||||||
|
* the same function (or, for top-level code, earlier in the file).
|
||||||
|
*
|
||||||
|
* Function scope rather than a fixed line window: an early-return guard at
|
||||||
|
* the top of a 60-line CLI command legitimately protects every call below
|
||||||
|
* it, and a window tight enough to be meaningful would reject that.
|
||||||
|
*
|
||||||
|
* @param string[] $lines Whole file, split on newlines.
|
||||||
|
* @param int $index Zero-based line index of the call site.
|
||||||
|
* @param string $class Class name being called.
|
||||||
|
*/
|
||||||
|
private function is_guarded( array $lines, int $index, string $class ): bool {
|
||||||
|
$from = 0;
|
||||||
|
for ( $i = $index; $i >= 0; $i-- ) {
|
||||||
|
if ( preg_match( '/^\t{1,2}(?:(?:public|private|protected|static|final|abstract)\s+)*function\s/', $lines[ $i ] ) ) {
|
||||||
|
$from = $i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$slice = implode( "\n", array_slice( $lines, $from, $index - $from + 1 ) );
|
||||||
|
return (bool) preg_match(
|
||||||
|
'/class_exists\(\s*[\'"]' . preg_quote( $class, '/' ) . '[\'"]/',
|
||||||
|
$slice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
if ( ! class_exists( 'WP_Post' ) ) {
|
||||||
|
class WP_Post {
|
||||||
|
public int $ID = 0;
|
||||||
|
public string $post_type = '';
|
||||||
|
public string $post_status = 'publish';
|
||||||
|
public string $post_title = '';
|
||||||
|
public string $post_content = '';
|
||||||
|
public int $post_author = 0;
|
||||||
|
public int $post_parent = 0;
|
||||||
|
public function __construct( object $data ) {
|
||||||
|
foreach ( (array) $data as $k => $v ) {
|
||||||
|
$this->$k = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concrete test double for TMDO_Standard_Post_Interceptor.
|
||||||
|
*/
|
||||||
|
class TMDO_Test_Post_Interceptor extends TMDO_Standard_Post_Interceptor {
|
||||||
|
|
||||||
|
protected string $module = 'test_module';
|
||||||
|
|
||||||
|
public const FIELD_MAP = array(
|
||||||
|
'test_meta' => 'test_col',
|
||||||
|
'other_meta' => 'other_col',
|
||||||
|
);
|
||||||
|
|
||||||
|
protected function get_post_type(): string {
|
||||||
|
return 'test_post';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function get_table_key(): string {
|
||||||
|
return 'test_table';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array {
|
||||||
|
return array(
|
||||||
|
'values' => array(
|
||||||
|
'post_id' => $post_id,
|
||||||
|
'status' => $post->post_status,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
),
|
||||||
|
'formats' => array( '%d', '%s', '%s', '%s' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for TMDO_Standard_Post_Interceptor abstract base.
|
||||||
|
*
|
||||||
|
* Exercises the three shared hook methods (action_delete_post,
|
||||||
|
* filter_update_meta, action_insert_post) via the TMDO_Test_Post_Interceptor
|
||||||
|
* concrete subclass, which never needs to live outside this test file.
|
||||||
|
*/
|
||||||
|
class StandardPostInterceptorTest extends TestCase {
|
||||||
|
|
||||||
|
private TMDO_Test_Post_Interceptor $interceptor;
|
||||||
|
|
||||||
|
/** Last $wpdb->delete() call: [table, where, formats]. */
|
||||||
|
public static array $last_delete = [];
|
||||||
|
|
||||||
|
/** Last SQL passed to $wpdb->query(). */
|
||||||
|
public static string $last_query = '';
|
||||||
|
|
||||||
|
/** Last $wpdb->insert() call: [table, data, formats]. */
|
||||||
|
public static array $last_insert = [];
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
self::$last_delete = [];
|
||||||
|
self::$last_query = '';
|
||||||
|
self::$last_insert = [];
|
||||||
|
|
||||||
|
$this->interceptor = new TMDO_Test_Post_Interceptor();
|
||||||
|
|
||||||
|
// Reset Feature Flags request cache.
|
||||||
|
$ff = new ReflectionClass( TMDO_Feature_Flags::class );
|
||||||
|
$ff->getProperty( 'cache' )->setValue( null, null );
|
||||||
|
$GLOBALS['_wp_options'] = [];
|
||||||
|
|
||||||
|
$this->setup_wpdb_mock();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setup_wpdb_mock(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb = new class {
|
||||||
|
public string $prefix = 'wp_';
|
||||||
|
public function prepare( string $sql, mixed ...$args ): string {
|
||||||
|
$i = 0;
|
||||||
|
return preg_replace_callback( '/%([sd])/', static function ( $m ) use ( &$i, $args ) {
|
||||||
|
$val = $args[ $i++ ] ?? '';
|
||||||
|
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
|
||||||
|
}, $sql );
|
||||||
|
}
|
||||||
|
public function query( string $sql ): int|bool {
|
||||||
|
StandardPostInterceptorTest::$last_query = $sql;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
public function delete( string $table, array $where, array $formats ): int|false {
|
||||||
|
StandardPostInterceptorTest::$last_delete = [ $table, $where, $formats ];
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
public function insert( string $table, array $data, array $formats ): int|false {
|
||||||
|
StandardPostInterceptorTest::$last_insert = [ $table, $data, $formats ];
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function make_post( string $type = 'test_post', int $id = 1 ): WP_Post {
|
||||||
|
$post = new WP_Post( (object) [] );
|
||||||
|
$post->ID = $id;
|
||||||
|
$post->post_type = $type;
|
||||||
|
$post->post_status = 'publish';
|
||||||
|
$post->post_title = 'Test';
|
||||||
|
$post->post_content = '';
|
||||||
|
$post->post_author = 0;
|
||||||
|
$post->post_parent = 0;
|
||||||
|
return $post;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── action_delete_post ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_delete_post_calls_wpdb_delete_for_correct_type(): void {
|
||||||
|
TMDO_Feature_Flags::set( 'test_module', 'complete' );
|
||||||
|
$post = $this->make_post( 'test_post', 99 );
|
||||||
|
$this->interceptor->action_delete_post( 99, $post );
|
||||||
|
$this->assertStringContainsString( 'wp_test_table', self::$last_delete[0] ?? '' );
|
||||||
|
$this->assertSame( [ 'post_id' => 99 ], self::$last_delete[1] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_post_skips_wrong_post_type(): void {
|
||||||
|
TMDO_Feature_Flags::set( 'test_module', 'complete' );
|
||||||
|
$post = $this->make_post( 'other_type', 99 );
|
||||||
|
$this->interceptor->action_delete_post( 99, $post );
|
||||||
|
$this->assertSame( [], self::$last_delete );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_post_skips_when_not_active(): void {
|
||||||
|
TMDO_Feature_Flags::set( 'test_module', 'idle' );
|
||||||
|
$post = $this->make_post( 'test_post', 99 );
|
||||||
|
$this->interceptor->action_delete_post( 99, $post );
|
||||||
|
$this->assertSame( [], self::$last_delete );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── filter_update_meta ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_update_meta_issues_sql_update_for_known_key(): void {
|
||||||
|
TMDO_Feature_Flags::set( 'test_module', 'complete' );
|
||||||
|
// get_post_type() stub returns 'test_post' for post ID 5.
|
||||||
|
$GLOBALS['_wp_post_types'][5] = 'test_post';
|
||||||
|
|
||||||
|
$result = $this->interceptor->filter_update_meta( null, 5, 'test_meta', 'newval', '' );
|
||||||
|
|
||||||
|
$this->assertNull( $result );
|
||||||
|
$this->assertStringContainsString( 'UPDATE', self::$last_query );
|
||||||
|
$this->assertStringContainsString( 'test_col', self::$last_query );
|
||||||
|
$this->assertStringContainsString( 'newval', self::$last_query );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_meta_skips_unregistered_key(): void {
|
||||||
|
TMDO_Feature_Flags::set( 'test_module', 'complete' );
|
||||||
|
$GLOBALS['_wp_post_types'][5] = 'test_post';
|
||||||
|
|
||||||
|
$this->interceptor->filter_update_meta( null, 5, 'unknown_key', 'val', '' );
|
||||||
|
|
||||||
|
$this->assertSame( '', self::$last_query );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_meta_skips_wrong_post_type(): void {
|
||||||
|
TMDO_Feature_Flags::set( 'test_module', 'complete' );
|
||||||
|
$GLOBALS['_wp_post_types'][5] = 'other_type';
|
||||||
|
|
||||||
|
$this->interceptor->filter_update_meta( null, 5, 'test_meta', 'val', '' );
|
||||||
|
|
||||||
|
$this->assertSame( '', self::$last_query );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_meta_passes_through_check_unchanged(): void {
|
||||||
|
TMDO_Feature_Flags::set( 'test_module', 'complete' );
|
||||||
|
$GLOBALS['_wp_post_types'][5] = 'test_post';
|
||||||
|
$sentinel = 'original_check';
|
||||||
|
|
||||||
|
$result = $this->interceptor->filter_update_meta( $sentinel, 5, 'test_meta', 'v', '' );
|
||||||
|
|
||||||
|
$this->assertSame( $sentinel, $result );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── action_insert_post ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_insert_post_calls_wpdb_insert_for_new_post(): void {
|
||||||
|
TMDO_Feature_Flags::set( 'test_module', 'complete' );
|
||||||
|
$post = $this->make_post( 'test_post', 42 );
|
||||||
|
$this->interceptor->action_insert_post( 42, $post, false );
|
||||||
|
$this->assertStringContainsString( 'wp_test_table', self::$last_insert[0] ?? '' );
|
||||||
|
$this->assertSame( 42, self::$last_insert[1]['post_id'] ?? 0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_insert_post_skips_updates(): void {
|
||||||
|
TMDO_Feature_Flags::set( 'test_module', 'complete' );
|
||||||
|
$post = $this->make_post( 'test_post', 42 );
|
||||||
|
$this->interceptor->action_insert_post( 42, $post, true );
|
||||||
|
$this->assertSame( [], self::$last_insert );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_insert_post_skips_wrong_post_type(): void {
|
||||||
|
TMDO_Feature_Flags::set( 'test_module', 'complete' );
|
||||||
|
$post = $this->make_post( 'other_type', 42 );
|
||||||
|
$this->interceptor->action_insert_post( 42, $post, false );
|
||||||
|
$this->assertSame( [], self::$last_insert );
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user