Merge PR #16: fix missing cost tracker methods + logger null guard
fix: missing cost tracker methods + logger null guard
This commit is contained in:
@@ -134,6 +134,65 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
return round( $monthly_spend, 4 ) >= round( $budget, 4 );
|
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.
|
* Get total estimated spend for the current calendar month.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
|||||||
*
|
*
|
||||||
* @return int|\WP_Error Attachment ID on success, WP_Error on failure.
|
* @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.
|
// Validate input.
|
||||||
if ( empty( $image_data['bytes'] ) ) {
|
if ( empty( $image_data['bytes'] ) ) {
|
||||||
return new \WP_Error( 'invalid_image_data', 'Image data (bytes) is empty.' );
|
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.
|
* @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();
|
$temp_dir = get_temp_dir();
|
||||||
$filename = 'prautoblogger_img_' . wp_generate_uuid4() . $this->get_extension_for_mime( $mime_type );
|
$filename = 'prautoblogger_img_' . wp_generate_uuid4() . $this->get_extension_for_mime( $mime_type );
|
||||||
$temp_path = $temp_dir . $filename;
|
$temp_path = $temp_dir . $filename;
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ class PRAutoBlogger_Image_Pipeline {
|
|||||||
? $image_a_result->get_error_message()
|
? $image_a_result->get_error_message()
|
||||||
: 'Image A generation produced no attachment ID.';
|
: '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.
|
// Generate Image B (source-driven prompt) if source data is available.
|
||||||
if ( null !== $source_data && ! empty( $source_data ) ) {
|
if ( null !== $source_data && ! empty( $source_data ) ) {
|
||||||
@@ -136,7 +138,9 @@ class PRAutoBlogger_Image_Pipeline {
|
|||||||
? $image_b_result->get_error_message()
|
? $image_b_result->get_error_message()
|
||||||
: 'Image B generation produced no attachment ID.';
|
: '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;
|
return $result;
|
||||||
@@ -153,7 +157,7 @@ class PRAutoBlogger_Image_Pipeline {
|
|||||||
* cost_usd: float,
|
* cost_usd: float,
|
||||||
* }|\WP_Error
|
* }|\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 {
|
try {
|
||||||
// Build the article-driven prompt.
|
// Build the article-driven prompt.
|
||||||
$prompt = $this->prompt_builder->build_article_prompt( $article_data );
|
$prompt = $this->prompt_builder->build_article_prompt( $article_data );
|
||||||
@@ -219,7 +223,7 @@ class PRAutoBlogger_Image_Pipeline {
|
|||||||
* cost_usd: float,
|
* cost_usd: float,
|
||||||
* }|\WP_Error
|
* }|\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 {
|
try {
|
||||||
// Build the source-driven prompt.
|
// Build the source-driven prompt.
|
||||||
$prompt = $this->prompt_builder->build_source_prompt( $source_data );
|
$prompt = $this->prompt_builder->build_source_prompt( $source_data );
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ class PRAutoBlogger_Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
global $wpdb;
|
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';
|
$table = $wpdb->prefix . 'prautoblogger_event_log';
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) {
|
|||||||
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
|
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.
|
// WordPress database constants used in $wpdb queries.
|
||||||
if ( ! defined( 'ARRAY_A' ) ) {
|
if ( ! defined( 'ARRAY_A' ) ) {
|
||||||
define( 'ARRAY_A', '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] : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ class ImagePipelineTest extends BaseTestCase {
|
|||||||
protected function setUp(): void {
|
protected function setUp(): void {
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
// Mock WordPress functions.
|
// Mock WordPress functions used by the pipeline and its dependencies.
|
||||||
Functions\when( 'wp_generate_uuid4' )->returnArg();
|
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 ) {
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||||
static $options = [
|
static $options = [
|
||||||
'prautoblogger_image_enabled' => '1',
|
'prautoblogger_image_enabled' => '1',
|
||||||
@@ -28,7 +31,12 @@ class ImagePipelineTest extends BaseTestCase {
|
|||||||
return $options[ $key ] ?? $default;
|
return $options[ $key ] ?? $default;
|
||||||
} );
|
} );
|
||||||
Functions\when( 'sanitize_text_field' )->returnArg();
|
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( '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;
|
return $options[ $key ] ?? $default;
|
||||||
} );
|
} );
|
||||||
|
|
||||||
// Mock the logger.
|
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||||
$logger = $this->createMock( \PRAutoBlogger_Logger::class );
|
|
||||||
$logger->expects( $this->once() )->method( 'info' );
|
|
||||||
|
|
||||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
|
||||||
$provider->expects( $this->never() )->method( 'generate_image' );
|
$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 );
|
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||||
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] );
|
$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 = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||||
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||||
$cost_tracker->method( 'log_image_generation' )->willReturn( true );
|
|
||||||
|
|
||||||
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
|||||||
|
|
||||||
// Mock WordPress functions.
|
// Mock WordPress functions.
|
||||||
Functions\when( 'wp_strip_all_tags' )->alias( function ( $str ) {
|
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( 'sanitize_text_field' )->returnArg();
|
||||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||||
|
|||||||
@@ -224,8 +224,7 @@ class OpenRouterModelRegistryTest extends BaseTestCase {
|
|||||||
return 'Connection timed out';
|
return 'Connection timed out';
|
||||||
};
|
};
|
||||||
Functions\when( 'wp_remote_get' )->alias( function () {
|
Functions\when( 'wp_remote_get' )->alias( function () {
|
||||||
$e = new \WP_Error_Stub();
|
return new \WP_Error( 'http_request_failed', 'Connection timed out' );
|
||||||
return $e;
|
|
||||||
} );
|
} );
|
||||||
|
|
||||||
// Accept that update_option will be called for the stale flag.
|
// 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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user