diff --git a/includes/core/class-image-pipeline.php b/includes/core/class-image-pipeline.php index c805621..90d1d4d 100644 --- a/includes/core/class-image-pipeline.php +++ b/includes/core/class-image-pipeline.php @@ -165,7 +165,14 @@ class PRAutoBlogger_Image_Pipeline { private function process_image_a( int $post_id, array $batch_results, array $captions, array &$result ): void { $image_data = $batch_results['image_a'] ?? null; if ( ! $image_data || isset( $image_data['error'] ) ) { - $result['errors'][] = $image_data['error'] ?? 'Image A missing from batch results.'; + $err_msg = $image_data['error'] ?? 'Image A missing from batch results.'; + $result['errors'][] = $err_msg; + // Log at WARNING so the path is visible even if the outer catch in the + // caller doesn't fire. See thread image-mime-bug Issue 2. + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'Image A generation failed for post %d: %s', $post_id, $err_msg ), + 'image_pipeline' + ); return; } @@ -196,7 +203,14 @@ class PRAutoBlogger_Image_Pipeline { private function process_image_b( int $post_id, array $batch_results, array $captions, array &$result ): void { $image_data = $batch_results['image_b'] ?? null; if ( ! $image_data || isset( $image_data['error'] ) ) { - $result['errors'][] = $image_data['error'] ?? 'Image B missing from batch results.'; + $err_msg = $image_data['error'] ?? 'Image B missing from batch results.'; + $result['errors'][] = $err_msg; + // Log at WARNING so the path is visible even if the outer catch in the + // caller doesn't fire. See thread image-mime-bug Issue 2. + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'Image B generation failed for post %d: %s', $post_id, $err_msg ), + 'image_pipeline' + ); return; } diff --git a/includes/providers/class-open-router-image-batch.php b/includes/providers/class-open-router-image-batch.php index 59b40e2..5703f5b 100644 --- a/includes/providers/class-open-router-image-batch.php +++ b/includes/providers/class-open-router-image-batch.php @@ -130,6 +130,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch { $results = []; foreach ( $handles as $key => $entry ) { $results[ $key ] = $this->collect_result( + (string) $key, $entry['handle'], $entry['model'], $entry['req'], @@ -169,21 +170,35 @@ class PRAutoBlogger_OpenRouter_Image_Batch { /** * Parse the result of one cURL handle into image data or error. * + * All failure paths emit `Logger::error` at the point they fold the + * request into an `['error' => ...]` array — tagged with the request + * `$key` so the pipeline can attribute which of the parallel images + * failed (image_a vs image_b) without cross-referencing timestamps. + * + * @param string $key Request key from the batch map (e.g. "image_a"). * @param \CurlHandle $ch Individual cURL handle (completed). * @param string $model Resolved model id. * @param array $req Original request array. * @param float $start Microtime when this handle was created. * * @return array Image data array or `{error: string}`. + * + * Side effects: writes `Logger::error` on any failure path. */ - private function collect_result( $ch, string $model, array $req, float $start ): array { + private function collect_result( string $key, $ch, string $model, array $req, float $start ): array { $latency_ms = (int) ( ( microtime( true ) - $start ) * 1000 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_errno $curl_errno = curl_errno( $ch ); if ( 0 !== $curl_errno ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error - $msg = sprintf( 'cURL error %d: %s', $curl_errno, curl_error( $ch ) ); + $detail = curl_error( $ch ); + $msg = sprintf( + 'OpenRouter batch image gen cURL error %d for key "%s": %s', + $curl_errno, + $key, + $detail + ); PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' ); return [ 'error' => $msg ]; } @@ -194,7 +209,12 @@ class PRAutoBlogger_OpenRouter_Image_Batch { $raw = (string) curl_multi_getcontent( $ch ); if ( $http_code >= 400 ) { - $msg = sprintf( 'HTTP %d: %s', $http_code, substr( $raw, 0, 300 ) ); + $msg = sprintf( + 'OpenRouter batch image gen HTTP %d for key "%s": %s', + $http_code, + $key, + substr( $raw, 0, 300 ) + ); PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' ); return [ 'error' => $msg ]; } @@ -202,11 +222,13 @@ class PRAutoBlogger_OpenRouter_Image_Batch { try { $image_bytes = $this->support->extract_image_bytes( $raw ); } catch ( \Throwable $e ) { - PRAutoBlogger_Logger::instance()->error( - 'Batch parse error: ' . $e->getMessage(), - 'openrouter-image-batch' + $msg = sprintf( + 'OpenRouter batch image gen parse error for key "%s": %s', + $key, + $e->getMessage() ); - return [ 'error' => $e->getMessage() ]; + PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' ); + return [ 'error' => $msg ]; } return [ diff --git a/tests/unit/Core/ImagePipelineTest.php b/tests/unit/Core/ImagePipelineTest.php index 7409f7b..88b0b28 100644 --- a/tests/unit/Core/ImagePipelineTest.php +++ b/tests/unit/Core/ImagePipelineTest.php @@ -211,6 +211,59 @@ class ImagePipelineTest extends BaseTestCase { $this->assertStringContainsString( 'API timeout', implode( ' ', $result['errors'] ) ); } + /** + * Test that a Logger::warning row is persisted when image_data carries an error. + * + * Covers Issue 2 from the image-mime-bug thread: previously the pipeline + * pushed the error into $result['errors'] silently, with no WARNING-level + * event_log row visible to ops. This regression-guards that a WARNING row + * now fires whether or not the outer catch in the caller triggers. + */ + public function test_partial_batch_failure_emits_warning_log_row(): void { + // Seed Logger's log_level so warnings actually get written. + Functions\when( 'get_option' )->alias( function ( $key, $default = false ) { + static $options = [ + 'prautoblogger_image_enabled' => '1', + 'prautoblogger_log_level' => 'info', + ]; + return $options[ $key ] ?? $default; + } ); + + $mock_wpdb = $this->create_mock_wpdb(); + $captured_rows = []; + $mock_wpdb->method( 'insert' )->willReturnCallback( + function ( $table, $row ) use ( &$captured_rows ) { + $captured_rows[] = $row; + return 1; + } + ); + $GLOBALS['wpdb'] = $mock_wpdb; + + $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); + $provider->method( 'estimate_cost' )->willReturn( 0.05 ); + $provider->method( 'generate_image_batch' )->willReturn( [ + 'image_a' => [ 'error' => 'HTTP 400: flux-1-schnell is not a valid model ID' ], + ] ); + + $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); + $cost_tracker->method( 'would_exceed_budget' )->willReturn( false ); + + $pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker ); + $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null ); + + $warning_rows = array_filter( + $captured_rows, + static fn( $row ) => ( $row['level'] ?? '' ) === 'warning' + && ( $row['context'] ?? '' ) === 'image_pipeline' + ); + $this->assertNotEmpty( + $warning_rows, + 'Expected at least one image_pipeline WARNING row when image_data has an error key.' + ); + + unset( $GLOBALS['wpdb'] ); + } + /** * Test graceful handling when one image in the batch returns an error. */