From 1c3d9b50c63ef2b7dc67229e937618dad5024821 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 21 Apr 2026 16:45:23 +0000 Subject: [PATCH] cf-removal: delete Cloudflare Workers AI provider files + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the four CF provider classes and their two test files. The OpenRouter AI Gateway path is unaffected — it reads prautoblogger_ai_gateway_base_url via class-open-router-config.php directly, with no dependency on the CF support class. Leaves class-cost-tracker.php line 165 'cloudflare-workers-ai' label intact for historical log integrity. Part of v0.10.0 (removing CF Workers AI as image provider). See convo/prautoblogger/threads/2026-04-cloudflare-workers-ai-removal/. --- .../class-cloudflare-image-pricing.php | 78 --- .../class-cloudflare-image-provider.php | 296 ----------- .../class-cloudflare-image-support.php | 229 --------- .../class-cloudflare-image-validator.php | 122 ----- .../Providers/CloudflareImageProviderTest.php | 483 ------------------ .../Providers/CloudflareImageSupportTest.php | 115 ----- 6 files changed, 1323 deletions(-) delete mode 100644 includes/providers/class-cloudflare-image-pricing.php delete mode 100644 includes/providers/class-cloudflare-image-provider.php delete mode 100644 includes/providers/class-cloudflare-image-support.php delete mode 100644 includes/providers/class-cloudflare-image-validator.php delete mode 100644 tests/unit/Providers/CloudflareImageProviderTest.php delete mode 100644 tests/unit/Providers/CloudflareImageSupportTest.php diff --git a/includes/providers/class-cloudflare-image-pricing.php b/includes/providers/class-cloudflare-image-pricing.php deleted file mode 100644 index b1b2e6f..0000000 --- a/includes/providers/class-cloudflare-image-pricing.php +++ /dev/null @@ -1,78 +0,0 @@ - - */ - private const MODEL_ALIAS_MAP = [ - 'flux-1-schnell' => '@cf/black-forest-labs/flux-1-schnell', - ]; - - /** - * Normalize a caller's model hint to a fully-qualified Workers AI id. - * - * Accepts short aliases (`flux-1-schnell`), fully-qualified names - * (`@cf/black-forest-labs/...`), or empty. Empty input falls back to - * the site option, then to the hardcoded default. - * - * @param string $hint Caller-supplied model identifier (may be empty). - * @return string Fully-qualified Workers AI model id suitable for the URL path. - */ - public function resolve_model( string $hint ): string { - $hint = trim( $hint ); - if ( '' === $hint ) { - $hint = (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL ); - } - if ( 0 === strpos( $hint, '@cf/' ) ) { - return $hint; - } - return self::MODEL_ALIAS_MAP[ $hint ] ?? self::MODEL_ALIAS_MAP['flux-1-schnell']; - } - - /** - * Estimate USD cost for a single image at the given dimensions. - * - * FLUX is priced per megapixel of output. We clamp the MP floor at - * 0.01 so a tiny thumbnail doesn't round to zero and bypass the - * budget check. - * - * @param int $width Width in pixels. - * @param int $height Height in pixels. - * @param string $model Fully-qualified Workers AI model id (call resolve_model first). - * @return float USD cost rounded to 6 decimals. - */ - public function estimate_cost( int $width, int $height, string $model ): float { - $mp = max( 0.01, ( $width * $height ) / 1_000_000 ); - $rate = self::COST_PER_MP_SCHNELL_USD; - return round( $mp * $rate, 6 ); - } -} diff --git a/includes/providers/class-cloudflare-image-provider.php b/includes/providers/class-cloudflare-image-provider.php deleted file mode 100644 index 161a439..0000000 --- a/includes/providers/class-cloudflare-image-provider.php +++ /dev/null @@ -1,296 +0,0 @@ -pricing = new PRAutoBlogger_Cloudflare_Image_Pricing(); - $this->support = new PRAutoBlogger_Cloudflare_Image_Support(); - } - - /** - * Generate a single image from a prompt at the requested dimensions. - * - * Retries 5xx / 429 / network errors with exponential backoff bounded - * by PRAUTOBLOGGER_MAX_RETRIES. 4xx other than 429 fail loudly on the - * first attempt — retrying a bad prompt or a bad token wastes money. - * - * Side effects: one HTTP request per attempt; one Logger line per attempt. - * - * {@inheritDoc} - * - * @throws \InvalidArgumentException If dimensions out of range or prompt empty. - * @throws \RuntimeException On auth failure, bad response shape, or retry exhaustion. - */ - public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array { - // FLUX.1 requires dimensions divisible by 8; round to nearest valid value. - $width = (int) ( round( $width / 8 ) * 8 ); - $height = (int) ( round( $height / 8 ) * 8 ); - - $this->assert_valid_dimensions( $width, $height ); - $prompt = trim( $prompt ); - if ( '' === $prompt ) { - throw new \InvalidArgumentException( - esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' ) - ); - } - - $account_id = $this->support->get_account_id(); - $api_token = $this->support->get_api_token(); - $model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) ); - - $body = [ - 'prompt' => $prompt, - 'width' => $width, - 'height' => $height, - 'steps' => (int) ( $options['steps'] ?? self::DEFAULT_STEPS_SCHNELL ), - ]; - if ( isset( $options['seed'] ) ) { - $body['seed'] = (int) $options['seed']; - } - - $url = $this->support->build_endpoint_url( $account_id, $model ); - $headers = [ - 'Authorization' => 'Bearer ' . $api_token, - 'Content-Type' => 'application/json', - 'Accept' => 'image/*', - ]; - $started_at = microtime( true ); - $last_error = ''; - - for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) { - $response = wp_remote_post( - $url, - [ - 'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS, - 'headers' => $headers, - 'body' => wp_json_encode( $body ), - ] - ); - - if ( is_wp_error( $response ) ) { - $last_error = $response->get_error_message(); - $this->support->log_retryable_failure( $attempt, 'network', $last_error ); - $this->sleep_for_attempt( $attempt ); - continue; - } - - $status = (int) wp_remote_retrieve_response_code( $response ); - $raw = (string) wp_remote_retrieve_body( $response ); - - if ( 429 === $status || $status >= 500 ) { - $last_error = sprintf( 'HTTP %d: %s', $status, substr( $raw, 0, 300 ) ); - $this->support->log_retryable_failure( $attempt, (string) $status, $last_error ); - if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) { - $retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' ); - $this->sleep_for_attempt( $attempt, $retry_after ); - } - continue; - } - - if ( $status >= 400 ) { - $this->support->log_client_error( $status, $api_token, $account_id, $raw ); - $this->support->throw_if_nsfw( $raw ); - throw new \RuntimeException( - sprintf( - /* translators: %1$d: HTTP status, %2$s: error body. */ - esc_html__( 'Cloudflare Workers AI error (HTTP %1$d): %2$s', 'prautoblogger' ), - $status, - esc_html( substr( $raw, 0, 300 ) ) - ) - ); - } - - // Success. FLUX on Workers AI returns either raw image bytes - // (Content-Type: image/*) or a JSON envelope with base64 under - // `result.image`. Handle both without branching the caller. - $mime = (string) wp_remote_retrieve_header( $response, 'content-type' ); - [ $raw, $mime ] = $this->normalize_response_body( $raw, $mime ); - - return [ - 'bytes' => $raw, - 'mime_type' => $mime, - 'width' => $width, - 'height' => $height, - 'model' => $model, - 'seed' => isset( $body['seed'] ) ? (int) $body['seed'] : null, - 'cost_usd' => $this->pricing->estimate_cost( $width, $height, $model ), - 'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ), - ]; - } - - throw new \RuntimeException( - sprintf( - /* translators: %1$d: max retries, %2$s: last error. */ - esc_html__( 'Cloudflare Workers AI failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ), - PRAUTOBLOGGER_MAX_RETRIES, - esc_html( $last_error ) - ) - ); - } - - /** - * Sequential fallback — Cloudflare Workers AI does not support batching. - * - * {@inheritDoc} - */ - public function generate_image_batch( array $requests ): array { - $results = []; - foreach ( $requests as $key => $req ) { - try { - $results[ $key ] = $this->generate_image( $req['prompt'], $req['width'], $req['height'], $req['options'] ?? [] ); - } catch ( PRAutoBlogger_Image_NSFW_Blocked $e ) { - // Tag NSFW so the pipeline's retry layer can rebuild the prompt. - $results[ $key ] = [ 'error' => $e->getMessage(), 'error_type' => 'nsfw_blocked' ]; - } catch ( \Throwable $e ) { - $results[ $key ] = [ 'error' => $e->getMessage() ]; - } - } - return $results; - } - - /** - * Estimate the USD cost of a single generation call. - * - * {@inheritDoc} - */ - public function estimate_cost( int $width, int $height, array $options = [] ): float { - $model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) ); - return $this->pricing->estimate_cost( $width, $height, $model ); - } - - /** - * @return string Machine-readable provider id used in logs and settings. - */ - public function get_provider_name(): string { - return self::PROVIDER_ID; - } - - /** - * Verify credentials are present and the API is reachable. - * - * Delegates to PRAutoBlogger_Cloudflare_Image_Validator so the HTTP probe - * and error-shape code can be tested and sized independently. - * - * Side effects: one HTTP GET via the validator; one Logger line on failure. - * - * @return array{status: string, message: string, debug?: string} - */ - public function validate_credentials_detailed(): array { - return ( new PRAutoBlogger_Cloudflare_Image_Validator() )->run(); - } - - /** - * Enforce the interface's dimension contract. - * - * @param int $width Width in pixels. - * @param int $height Height in pixels. - * @return void - * @throws \InvalidArgumentException If either dimension is outside [MIN, MAX]. - */ - private function assert_valid_dimensions( int $width, int $height ): void { - if ( - $width < self::MIN_DIMENSION_PX || $width > self::MAX_DIMENSION_PX || - $height < self::MIN_DIMENSION_PX || $height > self::MAX_DIMENSION_PX - ) { - throw new \InvalidArgumentException( - sprintf( - /* translators: %1$d: width, %2$d: height, %3$d: min, %4$d: max. */ - esc_html__( 'Image dimensions %1$dx%2$d are outside the supported range (%3$d–%4$d px per axis).', 'prautoblogger' ), - $width, - $height, - self::MIN_DIMENSION_PX, - self::MAX_DIMENSION_PX - ) - ); - } - } - - /** - * Normalize a 2xx response into [bytes, mime-type] whether the body - * arrives as raw image bytes or as a JSON envelope with base64 image. - * - * @param string $raw Raw response body. - * @param string $mime Response Content-Type header value (may be empty). - * @return array{0: string, 1: string} [image bytes, mime type] - * @throws \RuntimeException If the body matches neither expected shape. - */ - private function normalize_response_body( string $raw, string $mime ): array { - if ( '' !== $mime && 0 === stripos( $mime, 'image/' ) ) { - return [ $raw, $mime ]; - } - $decoded = json_decode( $raw, true ); - if ( is_array( $decoded ) && isset( $decoded['result']['image'] ) && is_string( $decoded['result']['image'] ) ) { - $bytes = (string) base64_decode( $decoded['result']['image'], true ); - if ( '' !== $bytes ) { - return [ $bytes, 'image/png' ]; - } - } - throw new \RuntimeException( - esc_html__( 'Cloudflare Workers AI returned an unexpected response shape.', 'prautoblogger' ) - ); - } - - /** - * Sleep between retries. Retry-After header value wins when present, - * otherwise exponential backoff from the PRAUTOBLOGGER_* constants. - * - * @param int $attempt 1-based attempt number that just failed. - * @param int $retry_after_secs Retry-After header value (0 if absent). - * @return void - */ - private function sleep_for_attempt( int $attempt, int $retry_after_secs = 0 ): void { - $backoff = PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 ); - $delay = $retry_after_secs > 0 ? min( $retry_after_secs, self::RETRY_AFTER_CAP_S ) : $backoff; - if ( $delay > 0 ) { - sleep( $delay ); - } - } - -} diff --git a/includes/providers/class-cloudflare-image-support.php b/includes/providers/class-cloudflare-image-support.php deleted file mode 100644 index bd29b31..0000000 --- a/includes/providers/class-cloudflare-image-support.php +++ /dev/null @@ -1,229 +0,0 @@ -build_gateway_workers_ai_url( $account_id, $model ); - if ( '' !== $gateway_url ) { - return $gateway_url; - } - return sprintf( - 'https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s', - rawurlencode( $account_id ), - ltrim( $model, '/' ) - ); - } - - /** - * Derive a Workers AI URL rooted at the same AI Gateway we already use - * for OpenRouter. Returns empty string when the gateway isn't configured - * or the admin toggle opts out — the caller falls back to direct API. - * - * The existing `prautoblogger_ai_gateway_base_url` option points at - * `https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/openrouter` - * for the LLM path; we strip the `/openrouter` suffix and append - * `/workers-ai/{model}` for the image path. Defensive: only emits a URL - * that pattern-matches, so a bad option value quietly falls back. - * - * @param string $account_id Cloudflare account UUID (kept for symmetry; not used in URL derivation). - * @param string $model Fully-qualified Workers AI model id. - * @return string Gateway URL, or empty string if unavailable. - */ - private function build_gateway_workers_ai_url( string $account_id, string $model ): string { - unset( $account_id ); - if ( '1' !== (string) get_option( 'prautoblogger_cf_image_via_gateway', '1' ) ) { - return ''; - } - $gateway_base = rtrim( (string) get_option( 'prautoblogger_ai_gateway_base_url', '' ), '/' ); - if ( '' === $gateway_base ) { - return ''; - } - // Must be an https CF AI Gateway URL. - if ( 0 !== stripos( $gateway_base, 'https://gateway.ai.cloudflare.com/' ) ) { - return ''; - } - // The configured URL ends with `/openrouter` (LLM route). The gateway - // root — shared with the Workers AI route — is everything before the - // last `/`. If no `/` is present (defensive), we drop back to direct API. - $last_slash = strrpos( $gateway_base, '/' ); - if ( false === $last_slash ) { - return ''; - } - $gateway_root = substr( $gateway_base, 0, $last_slash ); - if ( '' === $gateway_root ) { - return ''; - } - return sprintf( '%s/workers-ai/%s', $gateway_root, ltrim( $model, '/' ) ); - } - - /** - * Emit a WARN line for a retryable failure. - * - * @param int $attempt 1-based attempt number. - * @param string $failure_class Either 'network' or HTTP status code as string. - * @param string $detail Short failure detail for the log. - * @return void - */ - public function log_retryable_failure( int $attempt, string $failure_class, string $detail ): void { - PRAutoBlogger_Logger::instance()->warning( - sprintf( - 'Cloudflare image gen %s failure (attempt %d/%d): %s', - $failure_class, - $attempt, - PRAUTOBLOGGER_MAX_RETRIES, - $detail - ), - 'cloudflare-image' - ); - } - - /** - * Log an unrecoverable 4xx with triage context. Never logs the full - * secret — only a 4-char token prefix and a 6-char account prefix. - * - * @param int $status HTTP status returned by Cloudflare. - * @param string $api_token Plaintext token. - * @param string $account_id Account UUID. - * @param string $raw Raw response body (truncated to 300 chars in the log). - * @return void - */ - public function log_client_error( int $status, string $api_token, string $account_id, string $raw ): void { - PRAutoBlogger_Logger::instance()->error( - sprintf( - 'Cloudflare image gen HTTP %d (token_prefix=%s, token_len=%d, account=%s): %s', - $status, - substr( $api_token, 0, 4 ), - strlen( $api_token ), - substr( $account_id, 0, 6 ), - substr( $raw, 0, 300 ) - ), - 'cloudflare-image' - ); - } - - /** - * Cloudflare Workers AI error code for NSFW content rejection. - * - * Observed on FLUX.1 schnell on 2026-04-20 when backfilling the post - * "Semaglutide vs Tirzepatide". Documented upstream as - * `AiError: Input prompt contains NSFW content.` - */ - public const ERROR_CODE_NSFW = 3030; - - /** - * Return true when the Cloudflare response body contains the NSFW - * content-filter error code (3030). - * - * The body shape is `{"errors":[{"code":3030,"message":"..."}],...}`. - * Parsing is tolerant: any JSON decode failure or missing key returns - * false, so a malformed 4xx never masquerades as NSFW. - * - * @param string $raw Raw response body from Cloudflare. - * @return bool True when at least one error entry carries code 3030. - */ - public function is_nsfw_error( string $raw ): bool { - $decoded = json_decode( $raw, true ); - if ( ! is_array( $decoded ) || empty( $decoded['errors'] ) || ! is_array( $decoded['errors'] ) ) { - return false; - } - foreach ( $decoded['errors'] as $err ) { - if ( is_array( $err ) && (int) ( $err['code'] ?? 0 ) === self::ERROR_CODE_NSFW ) { - return true; - } - } - return false; - } - - /** - * Throw a typed NSFW exception when the response body signals CF's - * content-filter rejection (code 3030). No-op otherwise. - * - * Centralised so callers stay at one line at the call site and so the - * exception-construction shape can't drift between call sites. - * - * @param string $raw Raw response body. - * @return void - * @throws PRAutoBlogger_Image_NSFW_Blocked When the body contains code 3030. - */ - public function throw_if_nsfw( string $raw ): void { - if ( ! $this->is_nsfw_error( $raw ) ) { - return; - } - throw new PRAutoBlogger_Image_NSFW_Blocked( - sprintf( - /* translators: %s: truncated error body. */ - esc_html__( 'Cloudflare Workers AI rejected the prompt as NSFW: %s', 'prautoblogger' ), - esc_html( substr( $raw, 0, 300 ) ) - ), - $raw - ); - } -} diff --git a/includes/providers/class-cloudflare-image-validator.php b/includes/providers/class-cloudflare-image-validator.php deleted file mode 100644 index 1aa295d..0000000 --- a/includes/providers/class-cloudflare-image-validator.php +++ /dev/null @@ -1,122 +0,0 @@ -err( - 'account_id_empty', - __( 'Cloudflare account ID is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' ) - ); - } - - $stored = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' ); - $api_token = ''; - if ( '' !== $stored ) { - // Handle legacy plaintext tokens (saved before the field was in the encrypted list). - $api_token = PRAutoBlogger_Encryption::is_encrypted( $stored ) - ? (string) PRAutoBlogger_Encryption::decrypt( $stored ) - : $stored; - } - if ( '' === $api_token ) { - return $this->err( - 'token_empty', - __( 'Cloudflare API token is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' ) - ); - } - - $response = wp_remote_get( - sprintf( - 'https://api.cloudflare.com/client/v4/accounts/%s/ai/models/search?per_page=1', - rawurlencode( $account_id ) - ), - [ - 'timeout' => self::TIMEOUT_SECONDS, - 'headers' => [ - 'Authorization' => 'Bearer ' . $api_token, - 'Content-Type' => 'application/json', - ], - ] - ); - - if ( is_wp_error( $response ) ) { - $msg = $response->get_error_message(); - PRAutoBlogger_Logger::instance()->error( - sprintf( 'Cloudflare credential check wp_error: %s', $msg ), - 'cloudflare-image' - ); - return $this->err( - 'wp_error:' . $msg, - sprintf( - /* translators: %s: transport-layer error message. */ - esc_html__( 'Network error reaching Cloudflare: %s', 'prautoblogger' ), - esc_html( $msg ) - ) - ); - } - - $status = (int) wp_remote_retrieve_response_code( $response ); - if ( 200 === $status ) { - return [ - 'status' => 'ok', - 'message' => __( 'Cloudflare Workers AI connected.', 'prautoblogger' ), - ]; - } - - $body = (string) wp_remote_retrieve_body( $response ); - PRAutoBlogger_Logger::instance()->warning( - sprintf( 'Cloudflare credential check HTTP %d: %s', $status, substr( $body, 0, 200 ) ), - 'cloudflare-image' - ); - return $this->err( - sprintf( 'http_%d', $status ), - sprintf( - /* translators: %1$d: HTTP status, %2$s: response body. */ - esc_html__( 'Cloudflare returned HTTP %1$d. %2$s', 'prautoblogger' ), - $status, - esc_html( substr( $body, 0, 200 ) ) - ) - ); - } - - /** - * @return array{status: string, message: string, debug: string} - */ - private function err( string $debug, string $message ): array { - return [ - 'status' => 'error', - 'message' => $message, - 'debug' => $debug, - ]; - } -} diff --git a/tests/unit/Providers/CloudflareImageProviderTest.php b/tests/unit/Providers/CloudflareImageProviderTest.php deleted file mode 100644 index 3f97a52..0000000 --- a/tests/unit/Providers/CloudflareImageProviderTest.php +++ /dev/null @@ -1,483 +0,0 @@ -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' => '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 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'] ); - } -} diff --git a/tests/unit/Providers/CloudflareImageSupportTest.php b/tests/unit/Providers/CloudflareImageSupportTest.php deleted file mode 100644 index da160e2..0000000 --- a/tests/unit/Providers/CloudflareImageSupportTest.php +++ /dev/null @@ -1,115 +0,0 @@ -stub_get_option( [ - 'prautoblogger_ai_gateway_base_url' => '', - 'prautoblogger_cf_image_via_gateway' => '1', - ] ); - - $support = new \PRAutoBlogger_Cloudflare_Image_Support(); - $url = $support->build_endpoint_url( self::ACCOUNT_ID, self::MODEL ); - - $this->assertStringStartsWith( 'https://api.cloudflare.com/client/v4/accounts/', $url ); - $this->assertStringContainsString( self::ACCOUNT_ID, $url ); - $this->assertStringEndsWith( self::MODEL, $url ); - } - - /** - * With the AI Gateway URL configured and the toggle on, image calls - * route through `gateway.ai.cloudflare.com/.../workers-ai/{model}` at - * the same gateway root as the OpenRouter LLM path. - */ - public function test_returns_gateway_url_when_configured_and_toggle_on(): void { - $this->stub_get_option( [ - 'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/' . self::ACCOUNT_ID . '/peptiderepo-prod/openrouter', - 'prautoblogger_cf_image_via_gateway' => '1', - ] ); - - $support = new \PRAutoBlogger_Cloudflare_Image_Support(); - $url = $support->build_endpoint_url( self::ACCOUNT_ID, self::MODEL ); - - $this->assertSame( - 'https://gateway.ai.cloudflare.com/v1/' . self::ACCOUNT_ID . '/peptiderepo-prod/workers-ai/' . self::MODEL, - $url - ); - } - - /** - * The toggle overrides the gateway URL: operators can fall back to the - * direct API if the gateway regresses, without clearing the OpenRouter - * gateway setting that the LLM provider depends on. - */ - public function test_toggle_off_forces_direct_url_even_with_gateway_configured(): void { - $this->stub_get_option( [ - 'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/' . self::ACCOUNT_ID . '/peptiderepo-prod/openrouter', - 'prautoblogger_cf_image_via_gateway' => '0', - ] ); - - $support = new \PRAutoBlogger_Cloudflare_Image_Support(); - $url = $support->build_endpoint_url( self::ACCOUNT_ID, self::MODEL ); - - $this->assertStringStartsWith( 'https://api.cloudflare.com/client/v4/accounts/', $url ); - } - - /** - * A malformed gateway URL (e.g. http, or a totally different host) is - * rejected and the direct-API path is used instead. Defensive — a bad - * option value shouldn't break image generation. - */ - public function test_falls_back_to_direct_when_gateway_url_is_malformed(): void { - $this->stub_get_option( [ - 'prautoblogger_ai_gateway_base_url' => 'http://evil.example.com/openrouter', - 'prautoblogger_cf_image_via_gateway' => '1', - ] ); - - $support = new \PRAutoBlogger_Cloudflare_Image_Support(); - $url = $support->build_endpoint_url( self::ACCOUNT_ID, self::MODEL ); - - $this->assertStringStartsWith( 'https://api.cloudflare.com/client/v4/accounts/', $url ); - } - - /** - * The NSFW detector must match on the documented error code without - * tripping on other 4xx bodies. (Covered at the provider level too, - * but worth pinning at the support class so a tolerance regression - * fails early.) - */ - public function test_is_nsfw_error_matches_code_3030_only(): void { - $support = new \PRAutoBlogger_Cloudflare_Image_Support(); - - $this->assertTrue( - $support->is_nsfw_error( '{"errors":[{"code":3030,"message":"NSFW"}]}' ) - ); - $this->assertFalse( - $support->is_nsfw_error( '{"errors":[{"code":7003,"message":"bad token"}]}' ) - ); - $this->assertFalse( - $support->is_nsfw_error( 'not json at all' ) - ); - $this->assertFalse( - $support->is_nsfw_error( '{}' ) - ); - } -}