Files
prautoblogger/tests/unit/Core/ImagePipelineTest.php
peptiderepo a3ed663bae fix(image-pipeline): surface per-image failures in the event log
Two gaps made posts 650/657 appear to silently "lose" their featured
images on 2026-04-20:

1. The batch provider tagged each failed key with ['error' => ...] but
   the ERROR log line it emitted didn't identify which key failed, so
   operators couldn't tell image_a from image_b in the event log when
   one of two parallel requests died.

2. Image_Pipeline::process_image_a/_b folded the per-key error into
   $result['errors'] and returned silently, with no WARNING row. Ops
   only learned about the miss when the outer catch in the caller fired
   — and on the 2026-04-20 incident that outer catch didn't fire, so
   nothing was logged at all except an unrelated pricing warning.

Fix:

- class-open-router-image-batch.php: collect_result() takes the request
  key as its first argument and every Logger::error message reads
  'OpenRouter batch image gen <class> for key "<key>": <detail>'.
  Matches the shape of OpenRouter_Image_Support::log_retry.

- class-image-pipeline.php: process_image_a and process_image_b now
  emit a Logger::warning('image_pipeline') row whenever $image_data is
  missing or carries an error key, in addition to pushing to
  $result['errors']. The warning fires whether or not the outer catch
  runs.

Test: ImagePipelineTest::test_partial_batch_failure_emits_warning_log_row
mocks $wpdb, runs the pipeline with image_a erroring, and asserts at
least one warning/image_pipeline row was inserted.

No behavior change beyond logging; cost impact zero.

Part of the v0.8.0 image bundle (issues from thread image-mime-bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:59:54 +07:00

309 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Tests for PRAutoBlogger_Image_Pipeline.
*
* Validates image generation orchestration, including A/B generation,
* cost tracking, and graceful failure handling.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ImagePipelineTest extends BaseTestCase {
protected function setUp(): void {
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 = [
'prautoblogger_image_enabled' => '1',
'prautoblogger_image_style_suffix' => 'Style: test style',
];
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( 'set_post_thumbnail' )->justReturn( true );
Functions\when( 'get_post_meta' )->justReturn( '' );
Functions\when( 'get_post' )->justReturn( (object) [ 'ID' => 1, 'post_content' => '<p>Test content</p>' ] );
Functions\when( 'wp_update_post' )->justReturn( 1 );
Functions\when( 'wp_get_attachment_url' )->justReturn( 'https://example.com/img.png' );
Functions\when( 'sanitize_title' )->returnArg();
Functions\when( 'esc_html__' )->returnArg();
Functions\when( 'esc_url' )->returnArg();
Functions\when( 'esc_attr' )->returnArg();
Functions\when( 'esc_html' )->returnArg();
}
/**
* Test generate_and_attach_images() returns early if disabled.
*/
public function test_generate_and_attach_images_skipped_if_disabled(): void {
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [ 'prautoblogger_image_enabled' => '0' ];
return $options[ $key ] ?? $default;
} );
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->expects( $this->never() )->method( 'generate_image' );
$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' ] );
$this->assertArrayHasKey( 'cost_usd', $result );
$this->assertArrayHasKey( 'errors', $result );
$this->assertEmpty( $result['errors'] );
}
/**
* Test generate_and_attach_images() returns cost and error array structure.
*
* Pipeline now calls generate_image_batch() with keyed requests.
*/
public function test_generate_and_attach_images_returns_correct_structure(): void {
$image_result = [
'bytes' => 'fake_image_data',
'mime_type' => 'image/png',
'width' => 1200,
'height' => 630,
'model' => 'test-model',
'seed' => 123,
'cost_usd' => 0.05,
'latency_ms' => 2000,
];
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
$provider->method( 'generate_image_batch' )->willReturn( [
'image_a' => $image_result,
] );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] );
$this->assertArrayHasKey( 'cost_usd', $result );
$this->assertArrayHasKey( 'errors', $result );
$this->assertIsFloat( $result['cost_usd'] );
$this->assertIsArray( $result['errors'] );
}
/**
* Test that cost is accumulated when both images are generated via batch.
*/
public function test_cost_is_accumulated_for_both_images(): void {
$image_result = [
'bytes' => 'fake_image_data',
'mime_type' => 'image/png',
'width' => 1200,
'height' => 630,
'model' => 'test-model',
'cost_usd' => 0.05,
'latency_ms' => 2000,
];
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
$provider->method( 'generate_image_batch' )->willReturn( [
'image_a' => $image_result,
'image_b' => $image_result,
] );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
$cost_tracker->expects( $this->exactly( 2 ) )->method( 'log_image_generation' );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
$result = $pipeline->generate_and_attach_images(
1,
[ 'post_title' => 'Test' ],
[ 'title' => 'Source' ]
);
// Cost should be accumulated from both images ($0.05 × 2 = $0.10).
$this->assertEqualsWithDelta( 0.10, $result['cost_usd'], 0.001 );
}
/**
* Test that Image B is NOT included in the batch when source_data is null.
*
* Regression test: the pipeline must only send 'image_a' in the batch
* request when no source data is provided.
*/
public function test_image_b_skipped_when_source_data_is_null(): void {
$image_result = [
'bytes' => 'fake_image_data',
'mime_type' => 'image/png',
'width' => 1200,
'height' => 630,
'model' => 'test-model',
'cost_usd' => 0.05,
'latency_ms' => 2000,
];
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
// Verify batch only contains 'image_a'.
$provider->expects( $this->once() )
->method( 'generate_image_batch' )
->with( $this->callback( function ( $requests ) {
return array_key_exists( 'image_a', $requests )
&& ! array_key_exists( 'image_b', $requests );
} ) )
->willReturn( [ 'image_a' => $image_result ] );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
$cost_tracker->expects( $this->exactly( 1 ) )->method( 'log_image_generation' );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null );
$this->assertArrayHasKey( 'image_a_id', $result );
$this->assertArrayNotHasKey( 'image_b_id', $result );
}
/**
* Test graceful handling when batch generation throws.
*/
public function test_graceful_failure_when_batch_generation_throws(): void {
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
$provider->method( 'generate_image_batch' )->willThrowException( new \Exception( 'API timeout' ) );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] );
$this->assertIsArray( $result );
$this->assertNotEmpty( $result['errors'] );
$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.
*/
public function test_partial_batch_failure_handled_gracefully(): void {
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
$provider->method( 'generate_image_batch' )->willReturn( [
'image_a' => [
'bytes' => 'fake_image_data',
'mime_type' => 'image/png',
'width' => 1200,
'height' => 630,
'model' => 'test-model',
'cost_usd' => 0.05,
'latency_ms' => 2000,
],
'image_b' => [ 'error' => 'HTTP 429: rate limited' ],
] );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
// Only Image A gets logged — Image B failed.
$cost_tracker->expects( $this->exactly( 1 ) )->method( 'log_image_generation' );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
$result = $pipeline->generate_and_attach_images(
1,
[ 'post_title' => 'Test' ],
[ 'title' => 'Source' ]
);
// Image A succeeds, Image B records an error.
$this->assertArrayHasKey( 'image_a_id', $result );
$this->assertArrayNotHasKey( 'image_b_id', $result );
$this->assertNotEmpty( $result['errors'] );
$this->assertStringContainsString( '429', implode( ' ', $result['errors'] ) );
}
}