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-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..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; @@ -153,7 +157,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 +223,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 ); 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 diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 97a4152..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' ); @@ -92,3 +97,39 @@ if ( ! class_exists( 'WP_Query' ) ) { } } } + +// 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] : ''; + } + } +} diff --git a/tests/unit/Core/ImagePipelineTest.php b/tests/unit/Core/ImagePipelineTest.php index 93f3b2e..d04b8a8 100644 --- a/tests/unit/Core/ImagePipelineTest.php +++ b/tests/unit/Core/ImagePipelineTest.php @@ -18,8 +18,11 @@ 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( '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 = [ 'prautoblogger_image_enabled' => '1', @@ -28,7 +31,12 @@ 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 ); + Functions\when( 'sanitize_title' )->returnArg(); + Functions\when( 'esc_html__' )->returnArg(); } /** @@ -40,13 +48,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 +80,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 ); 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 ) { 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'; - } - } -}