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' => 'oops', 'response' => [ 'code' => 200 ], 'headers' => [ 'content-type' => 'text/html' ], ] ); Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); Functions\when( 'wp_remote_retrieve_body' )->justReturn( 'oops' ); 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'] ); } }