test(images): cover retry loop and validator edge cases

Addresses P1 feedback from QA verdict on 11108b3:
- Retry-path tests: 429-then-200, 500-then-200, persistent-502 exhausts
  all attempts and throws.
- Validator guardrail test: validate_credentials_detailed() asserts
  wp_remote_post is never called, locking in the non-destructive probe
  contract so no "Test Connection" click ever burns image-gen budget.
- Validator edge case: missing account_id returns 'account_id_empty'
  without touching the network.
- Bootstrap retry delay set to 0 in tests so the new retry-loop tests
  don't spend real wall time in sleep(). Production value unchanged
  (PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS = 2 in prautoblogger.php).

Risk flags:
- No code under test changed. Same provider diff as 11108b3; this
  commit is purely additional coverage + a zero-impact bootstrap tweak.

Agent-Session: cowork-2026-04-15-image-pipeline-commit-1a-qa-fixup
This commit is contained in:
peptiderepo
2026-04-15 10:14:38 +07:00
parent 11108b3bb5
commit c8236189cd
2 changed files with 180 additions and 1 deletions

View File

@@ -37,7 +37,9 @@ if ( ! defined( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS' ) ) {
define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 30 ); define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 30 );
} }
if ( ! defined( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS' ) ) { if ( ! defined( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS' ) ) {
define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 1 ); // Zero in the test bootstrap so retry-path tests don't spend real wall
// time in sleep(). Production default is 2 (see prautoblogger.php).
define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 0 );
} }
// Default model slugs used by Publisher, ChiefEditor, Activator, settings UI. // Default model slugs used by Publisher, ChiefEditor, Activator, settings UI.

View File

@@ -254,6 +254,183 @@ class CloudflareImageProviderTest extends BaseTestCase {
$this->assertSame( 'token_empty', $result['debug'] ); $this->assertSame( 'token_empty', $result['debug'] );
} }
/**
* validate_credentials_detailed reports 'account_id_empty' when the
* account ID option is blank, without touching the network.
*/
public function test_validate_credentials_reports_missing_account_id(): void {
$this->stub_get_option( [
'prautoblogger_cloudflare_ai_token' => 'not-empty',
'prautoblogger_cloudflare_account_id' => '',
'prautoblogger_log_level' => 'info',
] );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->validate_credentials_detailed();
$this->assertSame( 'error', $result['status'] );
$this->assertSame( 'account_id_empty', $result['debug'] );
}
/**
* validate_credentials_detailed must never invoke the generation endpoint.
* Asserted by tracking every wp_remote_post call: a real-image call is
* the only thing that POSTs to `/ai/run/...`. Validator uses GET on
* `/ai/models/search`, which is the free read-only path.
*/
public function test_validate_credentials_never_calls_generate_endpoint(): void {
$this->with_token( 'test-token' );
$post_calls = [];
Functions\when( 'wp_remote_post' )->alias(
function ( $url, $args ) use ( &$post_calls ) {
$post_calls[] = $url;
return [ 'body' => '', 'response' => [ 'code' => 500 ] ];
}
);
Functions\when( 'wp_remote_get' )->justReturn( [
'body' => '{"result":[]}',
'response' => [ 'code' => 200 ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"result":[]}' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$provider->validate_credentials_detailed();
$this->assertSame( [], $post_calls, 'validate_credentials_detailed() must not POST to /ai/run/ endpoints.' );
}
/**
* Retry path: a 500 followed by a 200 must yield the 200 payload.
* Exercises the exponential-backoff loop end-to-end without sleeping
* (bootstrap sets PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS to 0).
*/
public function test_generate_image_retries_on_5xx_then_succeeds(): void {
$this->with_token( 'test-token' );
$codes = [ 500, 200 ];
Functions\when( 'wp_remote_post' )->alias(
function () use ( &$codes ) {
$code = array_shift( $codes ) ?? 200;
return [
'body' => 500 === $code ? 'upstream hiccup' : self::FAKE_IMAGE_BYTES,
'response' => [ 'code' => $code ],
'headers' => [ 'content-type' => 500 === $code ? 'text/plain' : 'image/png' ],
];
}
);
$response_codes = [ 500, 200 ];
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
function () use ( &$response_codes ) {
return array_shift( $response_codes ) ?? 200;
}
);
$response_bodies = [ 'upstream hiccup', self::FAKE_IMAGE_BYTES ];
Functions\when( 'wp_remote_retrieve_body' )->alias(
function () use ( &$response_bodies ) {
return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES;
}
);
$mime_types = [ 'text/plain', 'image/png' ];
Functions\when( 'wp_remote_retrieve_header' )->alias(
function ( $_resp, $name ) use ( &$mime_types ) {
if ( 'content-type' === strtolower( (string) $name ) ) {
return array_shift( $mime_types ) ?? 'image/png';
}
return '';
}
);
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->generate_image( 'a prompt', 1080, 1080 );
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
}
/**
* Retry exhaustion: persistent 502s must throw after MAX_RETRIES attempts
* rather than hanging indefinitely.
*/
public function test_generate_image_fails_after_max_retries_on_persistent_5xx(): void {
$this->with_token( 'test-token' );
$call_count = 0;
Functions\when( 'wp_remote_post' )->alias(
function () use ( &$call_count ) {
++$call_count;
return [
'body' => 'bad gateway',
'response' => [ 'code' => 502 ],
'headers' => [],
];
}
);
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 502 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( 'bad gateway' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
try {
$provider->generate_image( 'a prompt', 1080, 1080 );
$this->fail( 'Expected RuntimeException after retry exhaustion.' );
} catch ( \RuntimeException $e ) {
$this->assertStringContainsString( '502', $e->getMessage() );
}
$this->assertSame(
PRAUTOBLOGGER_MAX_RETRIES,
$call_count,
'Provider must attempt exactly PRAUTOBLOGGER_MAX_RETRIES times before giving up.'
);
}
/**
* 429 triggers the same retry loop as 5xx (rate-limited, not deterministic
* error). The provider must retry and eventually succeed if the second
* attempt returns 200.
*/
public function test_generate_image_retries_on_429(): void {
$this->with_token( 'test-token' );
$codes = [ 429, 200 ];
Functions\when( 'wp_remote_post' )->alias(
function () use ( &$codes ) {
$code = array_shift( $codes ) ?? 200;
return [
'body' => 429 === $code ? 'slow down' : self::FAKE_IMAGE_BYTES,
'response' => [ 'code' => $code ],
'headers' => [ 'content-type' => 429 === $code ? 'text/plain' : 'image/png' ],
];
}
);
$response_codes = [ 429, 200 ];
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
function () use ( &$response_codes ) {
return array_shift( $response_codes ) ?? 200;
}
);
$response_bodies = [ 'slow down', self::FAKE_IMAGE_BYTES ];
Functions\when( 'wp_remote_retrieve_body' )->alias(
function () use ( &$response_bodies ) {
return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES;
}
);
$mime_types = [ 'text/plain', 'image/png' ];
Functions\when( 'wp_remote_retrieve_header' )->alias(
function ( $_resp, $name ) use ( &$mime_types ) {
if ( 'content-type' === strtolower( (string) $name ) ) {
return array_shift( $mime_types ) ?? 'image/png';
}
return '';
}
);
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->generate_image( 'a prompt', 1080, 1080 );
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
}
/** /**
* validate_credentials_detailed returns 'ok' on a 200 from the models * validate_credentials_detailed returns 'ok' on a 200 from the models
* listing endpoint. * listing endpoint.