* 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>
* feat(images): retry Cloudflare NSFW-blocked prompts with a sanitized fallback
Cloudflare's Workers AI FLUX.1 schnell content filter sometimes rejects
peptide-comic prompts as NSFW (HTTP 400, errors[].code === 3030). On
2026-04-20 this produced a silent publish-without-featured-image for
post #650 ("Semaglutide vs Tirzepatide") — the LLM-rewritten scene
tripped the filter and we had no recovery path.
New behaviour: on a 3030 response, the Cloudflare provider throws a
typed PRAutoBlogger_Image_NSFW_Blocked exception. The batch wrapper
catches it and tags the per-slot result with
`error_type: nsfw_blocked`. Image_Pipeline hands the results to a new
PRAutoBlogger_Image_NSFW_Retry helper, which reruns the blocked slot
once with a sanitized fallback prompt (title + style suffix, no LLM
rewrite). On success the retry result replaces the error entry; on a
second block, a WARNING is logged and the pipeline publishes without
that image — same terminal behaviour we had before, just explicit.
Files:
- New providers/class-image-nsfw-blocked.php — typed exception.
- providers/class-cloudflare-image-support.php — is_nsfw_error() +
throw_if_nsfw() helpers.
- providers/class-cloudflare-image-provider.php — calls throw_if_nsfw
inside the 4xx branch; batch catches the typed exception and marks
the result with error_type.
- core/class-image-prompt-builder.php — new public build_fallback_prompt
shim so the retry layer can reach the rule-based fallback without
going through the LLM path.
- New core/class-image-nsfw-retry.php — single-shot retry helper.
- core/class-image-pipeline.php — invokes the retry between batch
completion and per-slot processing.
- admin/class-settings-fields-extended.php — new toggle
`prautoblogger_image_nsfw_retry` (default on) in the Images section.
Tests:
- CloudflareImageProviderTest — 3030 response throws NSFW exception;
batch tags the per-slot result with `error_type: nsfw_blocked`.
- ImagePipelineTest — retry succeeds when provider returns a good
result on the second call; retry is skipped when the setting is off.
Cost impact: worst-case an extra Cloudflare call per NSFW-blocked slot
(~$0.000834). Current incidence ~2% of generations; ~+$0.00002 per
generation amortized. Immaterial.
Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.
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>
* feat(images): expose rewriter system prompt as admin setting
CEO feedback: every generated image is anchoring on the cartoon
peptide-with-arms-and-legs character because the hardcoded rewriter
system prompt both lists "anthropomorphized molecules" first in its
primer and ships a few-shot example that literally shows one. The ask
is to give CEO the lever without a code change.
Adds `prautoblogger_image_prompt_instructions` (textarea) in the
Images section. Default value is the existing REWRITER_SYSTEM_PROMPT
constant verbatim, so behaviour is unchanged on upgrade until someone
edits it. `Image_Prompt_Builder::rewrite_via_llm()` reads the option
at call time via a new `resolve_system_prompt()` helper and falls back
to the constant if the option is blank or whitespace-only, so a saved
empty value cannot brick image generation.
Const visibility flipped from `private` to `public` so the settings
field can reference it directly as the field default.
Tests:
- `test_rewrite_uses_setting_when_present` — option set, assert the
LLM's system message equals the option content.
- `test_rewrite_falls_back_to_default_when_setting_empty` — option
whitespace, assert the LLM's system message equals the constant.
Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.
Cost impact: none by default. Future CEO edits will change prompt
length; current default is ~180 tokens, budget impact linear from
there.
Part of the v0.8.0 image bundle (thread image-mime-bug, message 03).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(images): collapse Image Provider + Image Model into a single dropdown
Until now, Image Provider and Image Model were two independent selects
in the admin UI. That let the CEO set Model=flux-1-schnell while
Provider was still openrouter — OpenRouter has no such model so every
image gen on posts 650/657 on 2026-04-20 returned HTTP 400, and the
article published without a featured image.
v0.8.0 removes the Image Provider field from the admin UI entirely.
The provider is now derived from the model's entry in the registry
every time the model is saved. A new
PRAutoBlogger_Image_Model_Registry::provider_for() helper centralises
the mapping, so admin save, the on-upgrade migration, and future
callers all read from one place.
On save, PRAutoBlogger_Admin_Page::sanitize_image_model() validates
the submitted slug against the registry:
- Known slug: persist it, and update `prautoblogger_image_provider` to
the paired provider as a side-effect.
- Unknown slug: add_settings_error with a clear message, and return
the previously-saved value so the site stays on a working model.
A one-shot v0.8.0 migration in
PRAutoBlogger::on_check_db_version() derives the provider from the
currently-saved model on first admin_init after the plugin is updated.
This auto-heals sites that were running mismatched pairs before the
upgrade, without requiring the admin to re-open settings.
`prautoblogger_image_provider` option key is preserved — all existing
reads in the pipeline, cost tracker, and validators continue to work
unchanged.
Tests (Services/ImageModelRegistryTest):
- Every registry entry has an id and a provider in {openrouter, cloudflare}.
- provider_for() returns the paired provider for a known slug.
- provider_for() returns '' for unknown/empty slugs.
Cost impact: none.
Migration idempotent via the `prautoblogger_migrated_image_provider_v080`
flag. Uninstall sweeps it along with every other plugin option.
Part of the v0.8.0 image bundle (thread image-mime-bug, Issue 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v0.8.0): version bump + CHANGELOG + ARCHITECTURE.md for image bundle
Bump plugin header and PRAUTOBLOGGER_VERSION 0.7.3 → 0.8.0. Minor bump
because Commit D removes the Image Provider dropdown from admin (a
UX-visible change), not a patch.
CHANGELOG:
- Changed: collapsed Image Provider + Model dropdowns; provider derived.
- Added: Image Prompt Instructions setting; Retry NSFW-Blocked Images
setting.
- Fixed: silent per-image failures in the event log.
ARCHITECTURE.md:
- Stage 6b (Image Pipeline) callout now describes provider derivation
and the NSFW-retry fallback path.
- wp_options table lists the four new/changed keys
(`prautoblogger_image_provider` as derived, new
`prautoblogger_image_prompt_instructions`,
`prautoblogger_image_nsfw_retry`, and the one-shot migration flag
`prautoblogger_migrated_image_provider_v080`).
DB schema unchanged — no PRAUTOBLOGGER_DB_VERSION bump needed.
Final commit of the v0.8.0 image bundle (thread image-mime-bug).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
484 lines
18 KiB
PHP
484 lines
18 KiB
PHP
<?php
|
|
/**
|
|
* Tests for PRAutoBlogger_Cloudflare_Image_Provider.
|
|
*
|
|
* Validates the image provider interface implementation methods.
|
|
* All HTTP calls are mocked — no real API calls.
|
|
*
|
|
* @package PRAutoBlogger\Tests\Providers
|
|
*/
|
|
|
|
namespace PRAutoBlogger\Tests\Providers;
|
|
|
|
use PRAutoBlogger\Tests\BaseTestCase;
|
|
use Brain\Monkey\Functions;
|
|
|
|
class CloudflareImageProviderTest extends BaseTestCase {
|
|
|
|
/**
|
|
* Fake PNG payload — bytes don't need to be a real PNG, just non-empty.
|
|
* Using a short string keeps failure messages readable.
|
|
*/
|
|
private const FAKE_IMAGE_BYTES = 'FAKE_PNG_BYTES';
|
|
|
|
protected function setUp(): void {
|
|
parent::setUp();
|
|
|
|
// PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant is defined in
|
|
// prautoblogger.php — define here for test isolation.
|
|
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
|
|
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'flux-1-schnell' );
|
|
}
|
|
|
|
$this->stub_get_option( [
|
|
'prautoblogger_cloudflare_ai_token' => '',
|
|
'prautoblogger_cloudflare_account_id' => 'test-account-uuid',
|
|
'prautoblogger_image_model' => 'flux-1-schnell',
|
|
'prautoblogger_log_level' => 'info',
|
|
] );
|
|
|
|
Functions\when( 'wp_salt' )->justReturn( 'test_salt_key_for_unit_tests' );
|
|
Functions\when( 'is_wp_error' )->justReturn( false );
|
|
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
|
}
|
|
|
|
/**
|
|
* Helper: configure the plaintext API token the provider will eventually
|
|
* see after decryption. We round-trip through the real Encryption class
|
|
* (rather than stubbing it) so this test catches any regressions in the
|
|
* encrypt → option → decrypt flow at the same time.
|
|
*/
|
|
private function with_token( string $plaintext ): void {
|
|
$ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext );
|
|
$this->stub_get_option( [
|
|
'prautoblogger_cloudflare_ai_token' => $ciphertext,
|
|
'prautoblogger_cloudflare_account_id' => 'test-account-uuid',
|
|
'prautoblogger_image_model' => 'flux-1-schnell',
|
|
'prautoblogger_log_level' => 'info',
|
|
] );
|
|
}
|
|
|
|
/**
|
|
* Provider implements the interface.
|
|
*/
|
|
public function test_provider_implements_interface(): void {
|
|
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
$this->assertInstanceOf( \PRAutoBlogger_Image_Provider_Interface::class, $provider );
|
|
}
|
|
|
|
/**
|
|
* get_provider_name returns the stable machine identifier.
|
|
*/
|
|
public function test_provider_name_is_stable_identifier(): void {
|
|
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
$this->assertSame( 'cloudflare_workers_ai', $provider->get_provider_name() );
|
|
}
|
|
|
|
/**
|
|
* Dimension validation rejects inputs outside the [64, 2048] envelope
|
|
* before any HTTP call is made.
|
|
*/
|
|
public function test_generate_image_rejects_out_of_range_dimensions(): void {
|
|
$this->with_token( 'test-token' );
|
|
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
|
|
$this->expectException( \InvalidArgumentException::class );
|
|
$provider->generate_image( 'a prompt', 32, 1080 );
|
|
}
|
|
|
|
/**
|
|
* Empty prompts are rejected — an empty string burns budget on
|
|
* Workers AI for a deterministic "bad request" result.
|
|
*/
|
|
public function test_generate_image_rejects_empty_prompt(): void {
|
|
$this->with_token( 'test-token' );
|
|
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
|
|
$this->expectException( \InvalidArgumentException::class );
|
|
$provider->generate_image( ' ', 1080, 1080 );
|
|
}
|
|
|
|
/**
|
|
* Happy path — raw image bytes come back with expected metadata shape.
|
|
*/
|
|
public function test_generate_image_returns_raw_bytes_and_metadata(): void {
|
|
$this->with_token( 'test-token' );
|
|
|
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
'body' => self::FAKE_IMAGE_BYTES,
|
|
'response' => [ 'code' => 200 ],
|
|
'headers' => [ 'content-type' => 'image/png' ],
|
|
] );
|
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( self::FAKE_IMAGE_BYTES );
|
|
Functions\when( 'wp_remote_retrieve_header' )->alias(
|
|
function ( $_response, $name ) {
|
|
return 'content-type' === strtolower( (string) $name ) ? 'image/png' : '';
|
|
}
|
|
);
|
|
|
|
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
$result = $provider->generate_image( 'a prompt', 1080, 1080, [ 'seed' => 42 ] );
|
|
|
|
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
|
|
$this->assertSame( 'image/png', $result['mime_type'] );
|
|
$this->assertSame( 1080, $result['width'] );
|
|
$this->assertSame( 1080, $result['height'] );
|
|
$this->assertSame( 42, $result['seed'] );
|
|
$this->assertSame( '@cf/black-forest-labs/flux-1-schnell', $result['model'] );
|
|
$this->assertGreaterThan( 0.0, $result['cost_usd'] );
|
|
$this->assertGreaterThanOrEqual( 0, $result['latency_ms'] );
|
|
}
|
|
|
|
/**
|
|
* Happy path where Workers AI wraps the image in a JSON envelope with
|
|
* base64-encoded bytes under `result.image`. The provider must decode
|
|
* and return the raw bytes so the pipeline never has to branch.
|
|
*/
|
|
public function test_generate_image_decodes_json_envelope(): void {
|
|
$this->with_token( 'test-token' );
|
|
|
|
$encoded = base64_encode( self::FAKE_IMAGE_BYTES );
|
|
$body = wp_json_encode( [ 'result' => [ 'image' => $encoded ], 'success' => true ] );
|
|
|
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
'body' => $body,
|
|
'response' => [ 'code' => 200 ],
|
|
'headers' => [ 'content-type' => 'application/json' ],
|
|
] );
|
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body );
|
|
Functions\when( 'wp_remote_retrieve_header' )->alias(
|
|
function ( $_response, $name ) {
|
|
return 'content-type' === strtolower( (string) $name ) ? 'application/json' : '';
|
|
}
|
|
);
|
|
|
|
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
$result = $provider->generate_image( 'a prompt', 600, 600 );
|
|
|
|
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
|
|
$this->assertSame( 'image/png', $result['mime_type'] );
|
|
}
|
|
|
|
/**
|
|
* A 401 must NOT retry — auth errors are deterministic and retrying
|
|
* burns budget. The provider should throw on the first response.
|
|
*
|
|
* We can't assert "exactly one HTTP call" without injecting a call
|
|
* counter, so we assert that the throw message contains the status.
|
|
*/
|
|
public function test_generate_image_fails_loudly_on_401(): void {
|
|
$this->with_token( 'test-token' );
|
|
|
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
'body' => '{"errors":[{"message":"bad token"}]}',
|
|
'response' => [ 'code' => 401 ],
|
|
'headers' => [],
|
|
] );
|
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
|
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"bad token"}]}' );
|
|
|
|
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
|
|
try {
|
|
$provider->generate_image( 'a prompt', 1080, 1080 );
|
|
$this->fail( 'Expected RuntimeException for HTTP 401.' );
|
|
} catch ( \RuntimeException $e ) {
|
|
$this->assertStringContainsString( '401', $e->getMessage() );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A 400 carrying Cloudflare's NSFW code (3030) must throw the typed
|
|
* PRAutoBlogger_Image_NSFW_Blocked exception, not a generic
|
|
* RuntimeException. Image_NSFW_Retry keys off the typed exception to
|
|
* decide whether to rebuild the prompt and retry.
|
|
*/
|
|
public function test_generate_image_throws_nsfw_blocked_on_code_3030(): void {
|
|
$this->with_token( 'test-token' );
|
|
|
|
$nsfw_body = '{"errors":[{"message":"AiError: Input prompt contains NSFW content.","code":3030}]}';
|
|
|
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
'body' => $nsfw_body,
|
|
'response' => [ 'code' => 400 ],
|
|
'headers' => [],
|
|
] );
|
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 400 );
|
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $nsfw_body );
|
|
|
|
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
$this->expectException( \PRAutoBlogger_Image_NSFW_Blocked::class );
|
|
$provider->generate_image( 'some peptide prompt', 1080, 1080 );
|
|
}
|
|
|
|
/**
|
|
* A non-3030 4xx (e.g. 401 bad token) must NOT be tagged NSFW — the
|
|
* retry path would be wasted work there.
|
|
*/
|
|
public function test_generate_image_batch_tags_nsfw_errors_distinctly(): void {
|
|
$this->with_token( 'test-token' );
|
|
|
|
$nsfw_body = '{"errors":[{"message":"AiError: Input prompt contains NSFW content.","code":3030}]}';
|
|
|
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
'body' => $nsfw_body,
|
|
'response' => [ 'code' => 400 ],
|
|
'headers' => [],
|
|
] );
|
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 400 );
|
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $nsfw_body );
|
|
|
|
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
$results = $provider->generate_image_batch( [
|
|
'image_a' => [ 'prompt' => 'some prompt', 'width' => 1080, 'height' => 1080 ],
|
|
] );
|
|
|
|
$this->assertArrayHasKey( 'image_a', $results );
|
|
$this->assertSame( 'nsfw_blocked', $results['image_a']['error_type'] ?? null );
|
|
$this->assertArrayHasKey( 'error', $results['image_a'] );
|
|
}
|
|
|
|
/**
|
|
* Response shape must be one of: image/* bytes OR JSON envelope with
|
|
* base64 image. Anything else is a contract breach — fail loudly.
|
|
*/
|
|
public function test_generate_image_throws_on_unexpected_response_shape(): void {
|
|
$this->with_token( 'test-token' );
|
|
|
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
'body' => '<!doctype html><html><body>oops</body></html>',
|
|
'response' => [ 'code' => 200 ],
|
|
'headers' => [ 'content-type' => 'text/html' ],
|
|
] );
|
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '<!doctype html><html><body>oops</body></html>' );
|
|
Functions\when( 'wp_remote_retrieve_header' )->alias(
|
|
function ( $_response, $name ) {
|
|
return 'content-type' === strtolower( (string) $name ) ? 'text/html' : '';
|
|
}
|
|
);
|
|
|
|
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
$this->expectException( \RuntimeException::class );
|
|
$provider->generate_image( 'a prompt', 1080, 1080 );
|
|
}
|
|
|
|
/**
|
|
* Cost estimation is positive for in-range dimensions and scales with
|
|
* area — a 1080² image costs more than a 600² image on the same model.
|
|
*/
|
|
public function test_estimate_cost_scales_with_area(): void {
|
|
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
|
|
$small = $provider->estimate_cost( 600, 600, [ 'model' => 'flux-1-schnell' ] );
|
|
$large = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-schnell' ] );
|
|
|
|
$this->assertGreaterThan( 0.0, $small );
|
|
$this->assertGreaterThan( $small, $large );
|
|
}
|
|
|
|
/**
|
|
* [dev] is meaningfully pricier than [schnell] at the same dimensions —
|
|
* the ~4x multiplier is the reason we default to schnell.
|
|
*/
|
|
public function test_estimate_cost_dev_more_expensive_than_schnell(): void {
|
|
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
|
|
$schnell = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-schnell' ] );
|
|
$dev = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-dev' ] );
|
|
|
|
$this->assertGreaterThan( $schnell, $dev );
|
|
}
|
|
|
|
/**
|
|
* validate_credentials_detailed reports 'token_empty' debug tag when
|
|
* no token is configured, without making an HTTP call.
|
|
*/
|
|
public function test_validate_credentials_reports_missing_token(): void {
|
|
// Default setUp leaves token empty.
|
|
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
$result = $provider->validate_credentials_detailed();
|
|
|
|
$this->assertSame( 'error', $result['status'] );
|
|
$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' );
|
|
|
|
$response_codes = [ 500, 200 ];
|
|
$response_bodies = [ 'upstream hiccup', self::FAKE_IMAGE_BYTES ];
|
|
|
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
'body' => self::FAKE_IMAGE_BYTES,
|
|
'response' => [ 'code' => 200 ],
|
|
'headers' => [ 'content-type' => 'image/png' ],
|
|
] );
|
|
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
|
|
function () use ( &$response_codes ) {
|
|
return array_shift( $response_codes ) ?? 200;
|
|
}
|
|
);
|
|
Functions\when( 'wp_remote_retrieve_body' )->alias(
|
|
function () use ( &$response_bodies ) {
|
|
return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES;
|
|
}
|
|
);
|
|
// wp_remote_retrieve_header is called only on the success attempt —
|
|
// the 5xx branch returns before any header lookup. One justReturn
|
|
// for the content-type is enough; retry-after lookups accept the
|
|
// same stub and will coerce to (int) 0.
|
|
Functions\when( 'wp_remote_retrieve_header' )->justReturn( 'image/png' );
|
|
|
|
$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' );
|
|
|
|
$response_codes = [ 429, 200 ];
|
|
$response_bodies = [ 'slow down', self::FAKE_IMAGE_BYTES ];
|
|
|
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
'body' => self::FAKE_IMAGE_BYTES,
|
|
'response' => [ 'code' => 200 ],
|
|
'headers' => [ 'content-type' => 'image/png' ],
|
|
] );
|
|
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
|
|
function () use ( &$response_codes ) {
|
|
return array_shift( $response_codes ) ?? 200;
|
|
}
|
|
);
|
|
Functions\when( 'wp_remote_retrieve_body' )->alias(
|
|
function () use ( &$response_bodies ) {
|
|
return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES;
|
|
}
|
|
);
|
|
// Content-type is only read on the success attempt; retry-after is
|
|
// read on the 429 attempt and (int)-coerces anything non-numeric
|
|
// back to 0, falling through to exponential backoff.
|
|
Functions\when( 'wp_remote_retrieve_header' )->justReturn( 'image/png' );
|
|
|
|
$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
|
|
* listing endpoint.
|
|
*/
|
|
public function test_validate_credentials_ok_on_200(): void {
|
|
$this->with_token( 'test-token' );
|
|
Functions\when( 'wp_remote_get' )->justReturn( [
|
|
'body' => '{"result":[{"id":"@cf/black-forest-labs/flux-1-schnell"}]}',
|
|
'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();
|
|
$result = $provider->validate_credentials_detailed();
|
|
|
|
$this->assertSame( 'ok', $result['status'] );
|
|
}
|
|
}
|