cf-removal: delete Cloudflare Workers AI provider files + tests

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/.
This commit is contained in:
peptiderepo
2026-04-21 16:45:23 +00:00
parent 8bedecd2dd
commit 1c3d9b50c6
6 changed files with 0 additions and 1323 deletions

View File

@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
/**
* Pricing + model-id resolution helpers for the Cloudflare image provider.
*
* Kept separate from the provider so the provider stays under the 300-line
* cap and so cost estimation can be unit-tested without spinning up HTTP
* mocks. Mirrors the `class-open-router-pricing.php` split.
*
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider (both generate_image()
* and estimate_cost() delegate here).
* Dependencies: None — pure calculation + option reads.
*
* @see class-cloudflare-image-provider.php — Caller.
* @see class-open-router-pricing.php — Sibling pattern for LLM pricing.
*/
class PRAutoBlogger_Cloudflare_Image_Pricing {
/**
* Cloudflare Workers AI FLUX pricing (USD per megapixel), April 2026.
* Single source of truth — update here if Cloudflare reprices.
*/
private const COST_PER_MP_SCHNELL_USD = 0.0011;
/**
* Short-alias → fully-qualified Workers AI model id.
*
* FLUX.1 [dev] was removed from Cloudflare Workers AI in April 2026
* (returns HTTP 400 "No route for that URI"). Only schnell remains.
* Any saved setting referencing flux-1-dev falls through to schnell
* via the null-coalesce in resolve_model().
*
* @var array<string, string>
*/
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 );
}
}

View File

@@ -1,296 +0,0 @@
<?php
declare(strict_types=1);
/**
* Cloudflare Workers AI image provider — FLUX.1 family.
*
* Calls `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/{model}`
* directly. We bypass the Cloudflare AI Gateway for v1 because the gateway
* route to Workers AI currently 403s (see decision D-001); the OpenRouter
* gateway path is unaffected.
*
* Triggered by: PRAutoBlogger_Image_Pipeline (commit 1b) during the content
* generation run, after editorial review, before publish. Also
* triggered by the admin "Test Connection" action.
* Dependencies: PRAutoBlogger_Encryption (token decryption), PRAutoBlogger_Logger
* (WARN/ERROR lines), PRAutoBlogger_Cloudflare_Image_Pricing
* (model resolution + cost estimation).
*
* @see interface-image-provider.php Interface this class implements.
* @see class-cloudflare-image-pricing.php Cost + model id helpers.
* @see class-cloudflare-image-support.php Token, account, URL, and log helpers.
* @see class-cloudflare-image-validator.php Credential check helper.
* @see class-open-router-provider.php Sibling provider; same retry pattern.
* @see ARCHITECTURE.md External API Integrations, key decision #16.
*/
class PRAutoBlogger_Cloudflare_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
private const PROVIDER_ID = 'cloudflare_workers_ai';
private const MIN_DIMENSION_PX = 64;
private const MAX_DIMENSION_PX = 2048;
private const DEFAULT_STEPS_SCHNELL = 4;
private const RETRY_AFTER_CAP_S = 60;
/**
* Pricing + model-id resolver. Constructed once per provider instance.
*
* @var PRAutoBlogger_Cloudflare_Image_Pricing
*/
private PRAutoBlogger_Cloudflare_Image_Pricing $pricing;
/**
* Token, account, URL, and log helpers — pulled out of this class to
* keep it under the 300-line cap.
*
* @var PRAutoBlogger_Cloudflare_Image_Support
*/
private PRAutoBlogger_Cloudflare_Image_Support $support;
/**
* Construct the provider with its default pricing + support helpers.
*/
public function __construct() {
$this->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 );
}
}
}

View File

@@ -1,229 +0,0 @@
<?php
declare(strict_types=1);
/**
* Small support helpers for the Cloudflare image provider — credential
* lookups, URL building, and structured log lines.
*
* Split out of the provider so the provider stays under the 300-line cap
* and so these tiny pieces are individually testable. Pure functions plus
* one logger forward; no state.
*
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider only.
* Dependencies: PRAutoBlogger_Encryption (token decryption),
* PRAutoBlogger_Logger (WARN/ERROR forwarding).
*
* @see class-cloudflare-image-provider.php Sole caller.
*/
class PRAutoBlogger_Cloudflare_Image_Support {
/**
* Decrypt the Cloudflare API token from the settings option.
*
* @return string Plaintext token, or empty string if not set / decrypt fails.
*/
public function get_api_token(): string {
$stored = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
if ( '' === $stored ) {
return '';
}
// If the token is already encrypted (has "enc:" prefix), decrypt it.
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
return (string) PRAutoBlogger_Encryption::decrypt( $stored );
}
// Legacy: token was saved as plaintext before the field was added to
// the encrypted list in sanitize_field(). Encrypt it now so future
// reads go through the normal path, then return the plaintext value.
$cipher = PRAutoBlogger_Encryption::encrypt( $stored );
if ( '' !== $cipher ) {
update_option( 'prautoblogger_cloudflare_ai_token', $cipher );
}
return $stored;
}
/**
* Read the Cloudflare account ID from settings.
*
* @return string Account UUID, or empty string.
*/
public function get_account_id(): string {
return trim( (string) get_option( 'prautoblogger_cloudflare_account_id', '' ) );
}
/**
* Build the Workers AI run URL for a given account + model.
*
* By default routes through the Cloudflare AI Gateway we already use for
* OpenRouter — the gateway route to Workers AI was observed 403ing in
* April 2026 (see ARCHITECTURE.md #16), but manual probe on 2026-04-21
* shows it's now returning 200. Gateway routing gives us response caching,
* cost logging, rate limiting, and provider fallback for free.
*
* Direct-API fallback kicks in automatically when either the
* `prautoblogger_ai_gateway_base_url` option is empty (no gateway
* configured at all) or the new `prautoblogger_cf_image_via_gateway`
* toggle is off (explicit opt-out for if the gateway route regresses).
*
* @param string $account_id Cloudflare account UUID.
* @param string $model Fully-qualified Workers AI model id (e.g. @cf/black-forest-labs/flux-1-schnell).
* @return string Fully-formed HTTPS URL.
*/
public function build_endpoint_url( string $account_id, string $model ): string {
$gateway_url = $this->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
);
}
}

View File

@@ -1,122 +0,0 @@
<?php
declare(strict_types=1);
/**
* Credential + connectivity validator for the Cloudflare image provider.
*
* Split out of the provider to keep each file under the 300-line cap and
* to keep the validation path easy to unit-test without spinning up the
* retry loop. The provider's `validate_credentials_detailed()` delegates
* straight to `run()` here.
*
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider::validate_credentials_detailed()
* which is called by the admin "Test Connection" action.
* Dependencies: PRAutoBlogger_Encryption (token decryption), PRAutoBlogger_Logger
* (WARN/ERROR lines), wp_remote_get().
*
* @see class-cloudflare-image-provider.php Parent class that delegates here.
* @see interface-image-provider.php Return-shape contract.
*/
class PRAutoBlogger_Cloudflare_Image_Validator {
private const TIMEOUT_SECONDS = 15;
/**
* Run a non-destructive credential check.
*
* Lightweight probe: hits the account-scoped AI models listing endpoint.
* Never generates a real image — that would cost money on every click.
*
* Side effects: one HTTP GET; one Logger line on failure.
*
* @return array{status: string, message: string, debug?: string}
*/
public function run(): array {
$account_id = trim( (string) get_option( 'prautoblogger_cloudflare_account_id', '' ) );
if ( '' === $account_id ) {
return $this->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,
];
}
}

View File

@@ -1,483 +0,0 @@
<?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'] );
}
}

View File

@@ -1,115 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Cloudflare_Image_Support — URL building in
* particular. The v0.8.2 gateway migration added a derivation path from
* the existing `prautoblogger_ai_gateway_base_url` OpenRouter gateway
* URL to the corresponding Workers AI URL at the same gateway root.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class CloudflareImageSupportTest extends BaseTestCase {
private const ACCOUNT_ID = 'a16f4baa82a1074c47f696e7cb6d5995';
private const MODEL = '@cf/black-forest-labs/flux-1-schnell';
/**
* With no gateway URL configured, the support class returns the direct
* Workers AI API URL — the historical behaviour prior to v0.8.2.
*/
public function test_returns_direct_url_when_gateway_not_configured(): void {
$this->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( '{}' )
);
}
}