From f88d752a793c3ef9647d9806735095f229d80377 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 16:57:21 +0700 Subject: [PATCH 1/8] fix: add missing cost tracker methods + guard logger against null wpdb - Add `would_exceed_budget(float)` to pre-check budget before API calls - Add `log_image_generation()` for image pipeline cost logging - Guard Logger::log() against null $wpdb in unit test environments - Both methods called by image pipeline (commit 1b) but were missing Co-Authored-By: Claude Opus 4.6 --- includes/core/class-cost-tracker.php | 59 ++++++++++++++++++++++++++++ includes/core/class-logger.php | 5 +++ 2 files changed, 64 insertions(+) diff --git a/includes/core/class-cost-tracker.php b/includes/core/class-cost-tracker.php index fa91a9a..de6915a 100755 --- a/includes/core/class-cost-tracker.php +++ b/includes/core/class-cost-tracker.php @@ -134,6 +134,65 @@ class PRAutoBlogger_Cost_Tracker { return round( $monthly_spend, 4 ) >= round( $budget, 4 ); } + /** + * Log an image generation API call with a known cost. + * + * Unlike log_api_call() which calculates cost from token counts, image + * generation uses per-megapixel pricing that the image provider already + * computed. This method accepts the pre-calculated cost directly. + * + * @param float $cost_usd Estimated cost in USD (from image provider pricing). + * @param string $model Image model alias (e.g. 'flux-1-schnell'). + * @param int $post_id WordPress post ID the image is attached to. + * @param string $stage Pipeline stage ('image_a' or 'image_b'). + * @return void + */ + public function log_image_generation( float $cost_usd, string $model, int $post_id, string $stage ): void { + $this->current_run_cost += $cost_usd; + + global $wpdb; + if ( null === $wpdb ) { + return; + } + $table = $wpdb->prefix . 'prautoblogger_generation_log'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( $table, [ + 'post_id' => $post_id, + 'run_id' => $this->run_id, + 'stage' => $stage, + 'provider' => 'cloudflare-workers-ai', + 'model' => $model, + 'prompt_tokens' => 0, + 'completion_tokens' => 0, + 'estimated_cost' => $cost_usd, + 'response_status' => 'success', + 'error_message' => '', + 'created_at' => current_time( 'mysql' ), + ] ); + } + + /** + * Check if adding an estimated cost would exceed the monthly budget. + * + * Unlike is_budget_exceeded() which checks current state, this method + * proactively checks whether a planned expenditure would push spend + * over the budget. Used by the image pipeline to pre-check before + * making an API call. + * + * @param float $estimated_cost_usd Estimated cost in USD for the planned operation. + * @return bool True if (current spend + estimated cost) >= configured budget. + */ + public function would_exceed_budget( float $estimated_cost_usd ): bool { + $budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 ); + if ( $budget <= 0 ) { + return false; + } + + $projected = $this->get_monthly_spend() + $estimated_cost_usd; + return round( $projected, 4 ) >= round( $budget, 4 ); + } + /** * Get total estimated spend for the current calendar month. * diff --git a/includes/core/class-logger.php b/includes/core/class-logger.php index f887e3b..b9d3282 100755 --- a/includes/core/class-logger.php +++ b/includes/core/class-logger.php @@ -126,6 +126,11 @@ class PRAutoBlogger_Logger { } global $wpdb; + if ( null === $wpdb ) { + // No database available (unit test environment). PHP error_log + // was already called above for warnings/errors; silently skip DB write. + return; + } $table = $wpdb->prefix . 'prautoblogger_event_log'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery From 76b258bcc38c7016d6aa472b35b6e812709b9aac Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 17:05:12 +0700 Subject: [PATCH 2/8] fix: remove union return types for PHP 7.4 compatibility Union types (array|WP_Error, int|WP_Error, string|WP_Error) require PHP 8.0+. Removed return type declarations while keeping @return docblocks intact for IDE/static analysis support. Co-Authored-By: Claude Opus 4.6 --- includes/core/class-image-media-sideloader.php | 4 ++-- includes/core/class-image-pipeline.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/includes/core/class-image-media-sideloader.php b/includes/core/class-image-media-sideloader.php index 2128067..e007cfc 100644 --- a/includes/core/class-image-media-sideloader.php +++ b/includes/core/class-image-media-sideloader.php @@ -38,7 +38,7 @@ class PRAutoBlogger_Image_Media_Sideloader { * * @return int|\WP_Error Attachment ID on success, WP_Error on failure. */ - public function sideload_image( array $image_data, int $post_id, string $alt_text = '' ): int | \WP_Error { + public function sideload_image( array $image_data, int $post_id, string $alt_text = '' ) { // Validate input. if ( empty( $image_data['bytes'] ) ) { return new \WP_Error( 'invalid_image_data', 'Image data (bytes) is empty.' ); @@ -97,7 +97,7 @@ class PRAutoBlogger_Image_Media_Sideloader { * * @return string|\WP_Error Path to temporary file on success, WP_Error on failure. */ - private function create_temp_file( string $bytes, string $mime_type ): string | \WP_Error { + private function create_temp_file( string $bytes, string $mime_type ) { $temp_dir = get_temp_dir(); $filename = 'prautoblogger_img_' . wp_generate_uuid4() . $this->get_extension_for_mime( $mime_type ); $temp_path = $temp_dir . $filename; diff --git a/includes/core/class-image-pipeline.php b/includes/core/class-image-pipeline.php index 713aa85..1cdb085 100644 --- a/includes/core/class-image-pipeline.php +++ b/includes/core/class-image-pipeline.php @@ -153,7 +153,7 @@ class PRAutoBlogger_Image_Pipeline { * cost_usd: float, * }|\WP_Error */ - private function generate_image_a( int $post_id, array $article_data ): array | \WP_Error { + private function generate_image_a( int $post_id, array $article_data ) { try { // Build the article-driven prompt. $prompt = $this->prompt_builder->build_article_prompt( $article_data ); @@ -219,7 +219,7 @@ class PRAutoBlogger_Image_Pipeline { * cost_usd: float, * }|\WP_Error */ - private function generate_image_b( int $post_id, array $source_data ): array | \WP_Error { + private function generate_image_b( int $post_id, array $source_data ) { try { // Build the source-driven prompt. $prompt = $this->prompt_builder->build_source_prompt( $source_data ); From 38157f0b413c100ad060b09603779292cd75dc41 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 17:09:30 +0700 Subject: [PATCH 3/8] fix(tests): add wp_strip_all_tags and sanitize_text_field stubs Image prompt builder uses these WP text-processing functions. Without stubs, PHPUnit fails with undefined function errors. Co-Authored-By: Claude Opus 4.6 --- tests/bootstrap.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 97a4152..3a2f501 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -92,3 +92,19 @@ if ( ! class_exists( 'WP_Query' ) ) { } } } + +// Stub WP text-processing functions used by image prompt builder. +if ( ! function_exists( 'wp_strip_all_tags' ) ) { + function wp_strip_all_tags( $string, $remove_breaks = false ) { + $string = strip_tags( $string ); + if ( $remove_breaks ) { + $string = preg_replace( '/[\r\n\t ]+/', ' ', $string ); + } + return trim( $string ); + } +} +if ( ! function_exists( 'sanitize_text_field' ) ) { + function sanitize_text_field( $str ) { + return trim( strip_tags( $str ) ); + } +} From 4f13afbb77e2171efd1829b95e0c61cdcc361c7a Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 17:38:35 +0700 Subject: [PATCH 4/8] fix(tests): resolve Patchwork conflict and WP_Error_Stub issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove wp_strip_all_tags/sanitize_text_field stubs from bootstrap (Brain Monkey manages these via Functions\when() in individual tests; defining them early triggers Patchwork's DefinedTooEarly error) - Add WP_Error class stub to bootstrap (class, not function — safe for Patchwork) so model registry and image pipeline tests can use it - Replace WP_Error_Stub with standard WP_Error in model registry test Co-Authored-By: Claude Opus 4.6 --- tests/bootstrap.php | 44 ++++++++++++++----- .../Services/OpenRouterModelRegistryTest.php | 13 +----- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 3a2f501..83714a7 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -93,18 +93,38 @@ if ( ! class_exists( 'WP_Query' ) ) { } } -// Stub WP text-processing functions used by image prompt builder. -if ( ! function_exists( 'wp_strip_all_tags' ) ) { - function wp_strip_all_tags( $string, $remove_breaks = false ) { - $string = strip_tags( $string ); - if ( $remove_breaks ) { - $string = preg_replace( '/[\r\n\t ]+/', ' ', $string ); +// NOTE: Do NOT stub wp_strip_all_tags() or sanitize_text_field() here. +// Brain Monkey / Patchwork manages these stubs in individual tests via +// Functions\when(). Defining them in bootstrap causes Patchwork's +// "DefinedTooEarly" error. + +// WP_Error stub — used by model registry and image pipeline tests. +// This is safe to define here because it's a class (not a function), +// so Patchwork doesn't need to intercept its definition. +if ( ! class_exists( 'WP_Error' ) ) { + class WP_Error { + public $errors = []; + public $error_data = []; + + public function __construct( $code = '', $message = '', $data = '' ) { + if ( $code ) { + $this->errors[ $code ][] = $message; + if ( $data ) { + $this->error_data[ $code ] = $data; + } + } + } + + public function get_error_code() { + $codes = array_keys( $this->errors ); + return $codes ? $codes[0] : ''; + } + + public function get_error_message( $code = '' ) { + if ( ! $code ) { + $code = $this->get_error_code(); + } + return isset( $this->errors[ $code ] ) ? $this->errors[ $code ][0] : ''; } - return trim( $string ); - } -} -if ( ! function_exists( 'sanitize_text_field' ) ) { - function sanitize_text_field( $str ) { - return trim( strip_tags( $str ) ); } } diff --git a/tests/unit/Services/OpenRouterModelRegistryTest.php b/tests/unit/Services/OpenRouterModelRegistryTest.php index fcafcef..e246643 100644 --- a/tests/unit/Services/OpenRouterModelRegistryTest.php +++ b/tests/unit/Services/OpenRouterModelRegistryTest.php @@ -224,8 +224,7 @@ class OpenRouterModelRegistryTest extends BaseTestCase { return 'Connection timed out'; }; Functions\when( 'wp_remote_get' )->alias( function () { - $e = new \WP_Error_Stub(); - return $e; + return new \WP_Error( 'http_request_failed', 'Connection timed out' ); } ); // Accept that update_option will be called for the stale flag. @@ -240,13 +239,3 @@ class OpenRouterModelRegistryTest extends BaseTestCase { } } -/** - * Minimal WP_Error stub for testing is_wp_error() branches. - */ -if ( ! class_exists( 'WP_Error_Stub' ) ) { - class WP_Error_Stub { - public function get_error_message(): string { - return 'Connection timed out'; - } - } -} From 5e7fd882bc43affcb4b1634226a51aa84cd4f66e Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 17:43:43 +0700 Subject: [PATCH 5/8] fix(tests): rewrite ImagePipelineTest to fix three mock issues - Remove dead logger mock (pipeline uses static Logger::instance(), not injected) - Add wp_strip_all_tags Brain Monkey stub in setUp (removed from bootstrap) - Remove willReturn(true) on void log_image_generation mock - Fix wp_generate_uuid4 mock to use justReturn instead of returnArg Co-Authored-By: Claude Opus 4.6 --- tests/unit/Core/ImagePipelineTest.php | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/unit/Core/ImagePipelineTest.php b/tests/unit/Core/ImagePipelineTest.php index 93f3b2e..63e8164 100644 --- a/tests/unit/Core/ImagePipelineTest.php +++ b/tests/unit/Core/ImagePipelineTest.php @@ -18,8 +18,8 @@ class ImagePipelineTest extends BaseTestCase { protected function setUp(): void { parent::setUp(); - // Mock WordPress functions. - Functions\when( 'wp_generate_uuid4' )->returnArg(); + // Mock WordPress functions used by the pipeline and its dependencies. + Functions\when( 'wp_generate_uuid4' )->justReturn( 'test-uuid' ); Functions\when( 'get_option' )->alias( function ( $key, $default = false ) { static $options = [ 'prautoblogger_image_enabled' => '1', @@ -28,6 +28,9 @@ class ImagePipelineTest extends BaseTestCase { return $options[ $key ] ?? $default; } ); Functions\when( 'sanitize_text_field' )->returnArg(); + Functions\when( 'wp_strip_all_tags' )->alias( function ( $str ) { + return trim( strip_tags( $str ) ); + } ); Functions\when( 'update_post_meta' )->justReturn( true ); } @@ -40,13 +43,10 @@ class ImagePipelineTest extends BaseTestCase { return $options[ $key ] ?? $default; } ); - // Mock the logger. - $logger = $this->createMock( \PRAutoBlogger_Logger::class ); - $logger->expects( $this->once() )->method( 'info' ); - - $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); + $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); $provider->expects( $this->never() )->method( 'generate_image' ); - $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); + + $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); $pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker ); $result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] ); @@ -75,7 +75,6 @@ class ImagePipelineTest extends BaseTestCase { $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); $cost_tracker->method( 'would_exceed_budget' )->willReturn( false ); - $cost_tracker->method( 'log_image_generation' )->willReturn( true ); $pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker ); From e60cb83770406c97ed4ff7eb88ae7be8522ee169 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 17:47:03 +0700 Subject: [PATCH 6/8] fix: guard WP_Error cost access + add missing test constants - Wrap cost_usd accumulation in is_wp_error() guard so WP_Error returns from generate_image_a/b don't crash on array access - Add PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX to test bootstrap - Add is_wp_error, sanitize_title, esc_html__ mocks to ImagePipelineTest Co-Authored-By: Claude Opus 4.6 --- includes/core/class-image-pipeline.php | 8 ++++++-- tests/bootstrap.php | 5 +++++ tests/unit/Core/ImagePipelineTest.php | 5 +++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/includes/core/class-image-pipeline.php b/includes/core/class-image-pipeline.php index 1cdb085..02e1348 100644 --- a/includes/core/class-image-pipeline.php +++ b/includes/core/class-image-pipeline.php @@ -124,7 +124,9 @@ class PRAutoBlogger_Image_Pipeline { ? $image_a_result->get_error_message() : 'Image A generation produced no attachment ID.'; } - $result['cost_usd'] += $image_a_result['cost_usd'] ?? 0.0; + if ( ! is_wp_error( $image_a_result ) ) { + $result['cost_usd'] += $image_a_result['cost_usd'] ?? 0.0; + } // Generate Image B (source-driven prompt) if source data is available. if ( null !== $source_data && ! empty( $source_data ) ) { @@ -136,7 +138,9 @@ class PRAutoBlogger_Image_Pipeline { ? $image_b_result->get_error_message() : 'Image B generation produced no attachment ID.'; } - $result['cost_usd'] += $image_b_result['cost_usd'] ?? 0.0; + if ( ! is_wp_error( $image_b_result ) ) { + $result['cost_usd'] += $image_b_result['cost_usd'] ?? 0.0; + } } return $result; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 83714a7..6ff6748 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -55,6 +55,11 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) { define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' ); } +// Default image style suffix used by ImagePromptBuilder and settings UI. +if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) { + define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' ); +} + // WordPress database constants used in $wpdb queries. if ( ! defined( 'ARRAY_A' ) ) { define( 'ARRAY_A', 'ARRAY_A' ); diff --git a/tests/unit/Core/ImagePipelineTest.php b/tests/unit/Core/ImagePipelineTest.php index 63e8164..d04b8a8 100644 --- a/tests/unit/Core/ImagePipelineTest.php +++ b/tests/unit/Core/ImagePipelineTest.php @@ -19,6 +19,9 @@ class ImagePipelineTest extends BaseTestCase { parent::setUp(); // Mock WordPress functions used by the pipeline and its dependencies. + Functions\when( 'is_wp_error' )->alias( function ( $thing ) { + return $thing instanceof \WP_Error; + } ); Functions\when( 'wp_generate_uuid4' )->justReturn( 'test-uuid' ); Functions\when( 'get_option' )->alias( function ( $key, $default = false ) { static $options = [ @@ -32,6 +35,8 @@ class ImagePipelineTest extends BaseTestCase { return trim( strip_tags( $str ) ); } ); Functions\when( 'update_post_meta' )->justReturn( true ); + Functions\when( 'sanitize_title' )->returnArg(); + Functions\when( 'esc_html__' )->returnArg(); } /** From 941fc96c53bfc2ddf77ea36103866c8779784d19 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 18:04:13 +0700 Subject: [PATCH 7/8] =?UTF-8?q?ci:=20retrigger=20=E2=80=94=20previous=20PH?= =?UTF-8?q?PUnit=20runs=20stuck=20on=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 From 364d2468631b29d4d754a32570ecf4c9409f608e Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 18:18:13 +0700 Subject: [PATCH 8/8] fix(tests): infinite recursion in ImagePromptBuilderTest wp_strip_all_tags mock was aliased to call itself, causing the PHPUnit process to hang until the runner timed out. Replace with strip_tags() like other test files. Co-Authored-By: Claude Opus 4.6 --- tests/unit/Core/ImagePromptBuilderTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/Core/ImagePromptBuilderTest.php b/tests/unit/Core/ImagePromptBuilderTest.php index c900850..505e704 100644 --- a/tests/unit/Core/ImagePromptBuilderTest.php +++ b/tests/unit/Core/ImagePromptBuilderTest.php @@ -20,7 +20,7 @@ class ImagePromptBuilderTest extends BaseTestCase { // Mock WordPress functions. Functions\when( 'wp_strip_all_tags' )->alias( function ( $str ) { - return wp_strip_all_tags( $str ); + return trim( strip_tags( $str ) ); } ); Functions\when( 'sanitize_text_field' )->returnArg(); Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {