feat(images): Cloudflare Workers AI image provider (commit 1a)
First commit of the image + Instagram A/B pipeline workstream. Ships the Cloudflare Workers AI image provider, its pricing + validator helpers, four new settings fields in a new "Images" admin section, and unit tests with mocked HTTP. Nothing calls the provider yet from the article pipeline — integration lands in commit 1b. Why: CEO-approved on 2026-04-15 (see convo/prautoblogger/threads/2026-04-image-pipeline/01-cto-handoff.md). Style is locked to the 90s infomercial pastiche; default model is FLUX.1 [schnell] at ~$0.0011/MP (~$0.01/article for 8 images across 4 placements x 2 variants). Risk flags: - Direct call to Cloudflare Workers AI bypassing AI Gateway (decision D-001, documented in convo/prautoblogger/decisions/). Gateway route to Workers AI currently 403s; OpenRouter gateway path unaffected. - prab_event_log structured writes deferred to commit 1b when the table lands (decision D-002). Logger-based logging is in place now. - No schema changes. No existing code paths touched outside settings. - Backward compatible: provider is instantiable but not wired to any consumer yet. Test plan: - CI runs PHPUnit including the new CloudflareImageProviderTest. - After merge: verify the new Images section appears on the settings page, the style-suffix textarea pre-fills with the infomercial prompt, and entering the existing CLOUDFLARE_AI_TOKEN + account UUID lets a future "Test Connection" button return OK (button ships in a later commit; the underlying validate_credentials_detailed() works today). Files: - includes/providers/interface-image-provider.php (adopted from CTO draft) - includes/providers/class-cloudflare-image-provider.php (283 lines) - includes/providers/class-cloudflare-image-pricing.php (78 lines) - includes/providers/class-cloudflare-image-validator.php (107 lines) - includes/admin/class-settings-fields.php (+ Images section, 4 fields) - prautoblogger.php (+ PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, _STYLE_SUFFIX) - tests/unit/Providers/CloudflareImageProviderTest.php - ARCHITECTURE.md (file tree, integrations table, options table, decision #16) - CONVENTIONS.md (How To: Add a New Image Provider) - CHANGELOG.md (Unreleased section) Agent-Session: cowork-2026-04-15-image-pipeline-commit-1a
This commit is contained in:
275
tests/unit/Providers/CloudflareImageProviderTest.php
Normal file
275
tests/unit/Providers/CloudflareImageProviderTest.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?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() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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'] );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user