From 8e6b2d528fd30084089ebe2d86de2c455e5e4524 Mon Sep 17 00:00:00 2001 From: peptiderepo <273788462+peptiderepo@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:25:46 +0700 Subject: [PATCH] Commit 1a: Cloudflare Workers AI image provider + pricing + validator (#9) Implements image-provider interface + Cloudflare Workers AI adapter with pricing and validator. Independent QA verdict: APPROVE on 52fd11e (qa-reviews/prautoblogger/2026-04-15-52fd11e2ce97dd9e659df5cc87ec1382a668bec1.md). All 4 CTO blockers resolved (PHPUnit retry mocks, XSS hardening on error strings, PHPCS on new files, 300-line cap via support-class extraction). PHPCS backlog (586 errors across 38 pre-existing files) remains on continue-on-error; Workstream C material. --- ARCHITECTURE.md | 13 + CHANGELOG.md | 32 ++ CONVENTIONS.md | 28 ++ includes/admin/class-settings-fields.php | 44 ++ .../class-cloudflare-image-pricing.php | 78 ++++ .../class-cloudflare-image-provider.php | 271 +++++++++++ .../class-cloudflare-image-support.php | 101 ++++ .../class-cloudflare-image-validator.php | 116 +++++ .../providers/interface-image-provider.php | 100 ++++ prautoblogger.php | 13 + tests/bootstrap.php | 4 +- .../Providers/CloudflareImageProviderTest.php | 432 ++++++++++++++++++ 12 files changed, 1231 insertions(+), 1 deletion(-) create mode 100644 includes/providers/class-cloudflare-image-pricing.php create mode 100644 includes/providers/class-cloudflare-image-provider.php create mode 100644 includes/providers/class-cloudflare-image-support.php create mode 100644 includes/providers/class-cloudflare-image-validator.php create mode 100644 includes/providers/interface-image-provider.php create mode 100644 tests/unit/Providers/CloudflareImageProviderTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8060e30..619861b 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -132,6 +132,11 @@ prautoblogger/ │ │ ├── interface-source-provider.php # Contract for any social media source │ │ ├── class-reddit-json-client.php # Reddit HTTP client — RSS (primary) + .json (fallback) │ │ ├── class-reddit-provider.php # Reddit data collection orchestrator (RSS primary) +│ │ ├── interface-image-provider.php # Contract for any image generation provider +│ │ ├── class-cloudflare-image-provider.php # FLUX.1 via Cloudflare Workers AI +│ │ ├── class-cloudflare-image-pricing.php # Model alias + per-MP cost estimation +│ │ ├── class-cloudflare-image-validator.php # Non-destructive credential + connectivity check +│ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for the CF provider │ │ └── (new providers go here — see CONVENTIONS.md) │ │ │ ├── frontend/ @@ -338,6 +343,10 @@ All prefixed with `prautoblogger_`: | `prautoblogger_log_level` | Logging threshold: error/warning/info/debug | | `prautoblogger_db_version` | Schema version for migrations | | `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') | +| `prautoblogger_cloudflare_ai_token` | Encrypted Cloudflare Workers AI API token | +| `prautoblogger_cloudflare_account_id` | Cloudflare account UUID (plaintext — identifier, not secret) | +| `prautoblogger_image_model` | Image model alias: `flux-1-schnell` (default) or `flux-1-dev` | +| `prautoblogger_image_style_suffix` | Text appended to every image prompt (default: CEO-locked 90s infomercial prompt) | ### Post Meta @@ -368,6 +377,7 @@ Stored on every PRAutoBlogger-generated post: | Reddit RSS | Primary Reddit data source — Atom feeds for subreddit hot posts | None (unauthenticated) | No known rate limit; reliable from datacenter IPs | `providers/class-reddit-json-client.php` | | Reddit .json | Fallback for posts + only source for comments | None (unauthenticated) | ~10 req/min (datacenter IPs often blocked) | `providers/class-reddit-json-client.php` | | Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` | +| Cloudflare Workers AI | Image generation (FLUX.1 schnell / dev) for article hero, thumbnail, and IG placements | API token (encrypted in wp_options) + account ID | Workers AI per-account quotas | `providers/class-cloudflare-image-provider.php`, `providers/class-cloudflare-image-pricing.php`, `providers/class-cloudflare-image-validator.php` | --- @@ -415,6 +425,9 @@ Each pipeline execution generates a UUID (`run_id`) that tags every `prab_genera ### #14: Reddit RSS replaces PullPush.io (and earlier Reddit OAuth) Reddit rejected our OAuth API application (April 2026). We initially switched to PullPush.io, but its index was frequently stale or unavailable. Reddit's RSS/Atom feeds (`/r/{sub}/hot.rss`) proved the most reliable option — they work from datacenter IPs where .json gets 403, require no auth, and have no apparent rate limit. The .json endpoints are kept as a fallback for posts and as the sole source for comment data. Each collected item's metadata includes a `data_source` field (`reddit_rss` or `reddit_json`) for auditability. +### #16: Image generation via Cloudflare Workers AI (FLUX.1), direct (not via AI Gateway) +The image-generation layer (started 2026-04-15, tracked in `convo/prautoblogger/threads/2026-04-image-pipeline/`) uses FLUX.1 [schnell] on Cloudflare Workers AI as its default model, chosen for its ~$0.0011/MP cost and 2–3 sec latency. FLUX.1 [dev] is a ~4× cost upgrade exposed as a dropdown for specific posts that warrant it. Calls go directly to `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/@cf/black-forest-labs/...`, *not* through the Cloudflare AI Gateway that we use for OpenRouter — the gateway route to Workers AI currently 403s (pre-existing open issue). The provider lives behind `PRAutoBlogger_Image_Provider_Interface`, so the decision is trivially reversible: switching to a different image API (DALL-E, Replicate, a future AI Gateway route) is a new class implementation plus a one-line swap in the pipeline wiring. Trade-off: we run two different Cloudflare integration paths (gateway for OpenRouter LLMs, direct for Workers AI images) until the gateway route stabilizes — minor cognitive overhead, zero functional downside. + ### #15: Optional Cloudflare AI Gateway in front of OpenRouter We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged. diff --git a/CHANGELOG.md b/CHANGELOG.md index 586ed82..70aa1fd 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ All notable changes to PRAutoBlogger will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project uses [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added +- **Image provider: Cloudflare Workers AI (FLUX.1 family).** First commit of + the image + Instagram A/B pipeline workstream. Ships the provider, its + pricing + validator helpers, four new settings fields in a new "Images" + admin section, and unit tests with mocked HTTP. Nothing calls the provider + yet from the article pipeline — integration lands in commit 1b. + - `includes/providers/interface-image-provider.php` (adopted from the CTO's + uncommitted draft) — contract for any image provider. + - `includes/providers/class-cloudflare-image-provider.php` — FLUX on Workers + AI, direct call to `/accounts/{id}/ai/run/...` (bypassing AI Gateway per + decision D-001); exponential-backoff retries on 429 / 5xx / network, + loud fail on 4xx; handles both raw-bytes and JSON-envelope response shapes. + - `includes/providers/class-cloudflare-image-pricing.php` — model alias → + full Workers AI id resolution + per-megapixel cost estimation. + - `includes/providers/class-cloudflare-image-validator.php` — non-destructive + "Test Connection" credential check that never generates a real image. + - Settings: `prautoblogger_cloudflare_ai_token` (encrypted), + `prautoblogger_cloudflare_account_id`, `prautoblogger_image_model` + (schnell / dev), `prautoblogger_image_style_suffix` (default = CEO-locked + 90s infomercial prompt). + - Constants: `PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL`, + `PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX`. + - Tests: `tests/unit/Providers/CloudflareImageProviderTest.php` covers + happy path (raw bytes + JSON envelope), dimension + empty-prompt + validation, 4xx loud-fail without retry, unexpected response shape, + cost scaling across models and dimensions, and missing-token diagnostics. +- ARCHITECTURE.md: new key decision #16 (image pipeline: Cloudflare Workers + AI), new external API integration row, new options rows, file tree updates. +- CONVENTIONS.md: new "How To: Add a New Image Provider" section. + ## [0.2.2] — 2026-04-12 ### Changed diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 63d1659..9a53e8c 100755 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -122,6 +122,34 @@ interface PRAutoBlogger_Source_Provider_Interface { --- +## How To: Add a New Image Provider + +1. Create `includes/providers/class-{name}-image-provider.php` +2. Implement `PRAutoBlogger_Image_Provider_Interface` (see `interface-image-provider.php`) +3. Required methods: `generate_image()`, `estimate_cost()`, `get_provider_name()`, `validate_credentials_detailed()` +4. Split helpers into companions if you'd exceed the 300-line cap. The Cloudflare provider splits into provider + pricing + validator — mirror that shape. +5. Add API credentials fields to `admin/class-settings-fields.php` under the `prautoblogger_images` section +6. Register the provider in `class-prautoblogger.php` loader once the image pipeline lands (commit 1b of the image workstream) +7. Update `ARCHITECTURE.md` file tree, external API integrations table, and the options table + +### Interface contract: +```php +interface PRAutoBlogger_Image_Provider_Interface { + public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array; // {bytes, mime_type, width, height, model, seed, cost_usd, latency_ms} + public function estimate_cost( int $width, int $height, array $options = [] ): float; + public function get_provider_name(): string; // e.g., 'cloudflare_workers_ai' + public function validate_credentials_detailed(): array; // {status, message, debug?} +} +``` + +### Retry + cost rules (non-negotiable) +- 5xx / 429 / network errors: retry with exponential backoff up to `PRAUTOBLOGGER_MAX_RETRIES`, respect `Retry-After` when present. +- 4xx other than 429: fail loudly on the first response — never retry a deterministic bad request. +- Validation check must be non-destructive (no image generation). +- Every attempt logs via `PRAutoBlogger_Logger` with context `{provider-slug}-image`. + +--- + ## How To: Add a New Admin Setting 1. Define the option key in the settings array in `admin/class-admin-page.php` → `get_settings_fields()` method diff --git a/includes/admin/class-settings-fields.php b/includes/admin/class-settings-fields.php index 620adac..9409722 100755 --- a/includes/admin/class-settings-fields.php +++ b/includes/admin/class-settings-fields.php @@ -57,6 +57,11 @@ class PRAutoBlogger_Settings_Fields { 'icon' => 'dashicons-chart-area', 'description' => __( 'Connect Google Analytics 4 for post performance scoring.', 'prautoblogger' ), ], + 'prautoblogger_images' => [ + 'title' => __( 'Images', 'prautoblogger' ), + 'icon' => 'dashicons-format-image', + 'description' => __( 'Cloudflare Workers AI credentials and image style controls.', 'prautoblogger' ), + ], ]; } @@ -329,6 +334,45 @@ class PRAutoBlogger_Settings_Fields { 'section' => 'prautoblogger_analytics', 'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ), ], + + // ── Images ───────────────────────────────────────────────── + [ + 'id' => 'prautoblogger_cloudflare_ai_token', + 'label' => __( 'Cloudflare API Token', 'prautoblogger' ), + 'type' => 'password', + 'section' => 'prautoblogger_images', + 'description' => __( 'Workers AI token with Read + Edit scope. Create at dash.cloudflare.com → AI → API Tokens.', 'prautoblogger' ), + 'icon' => '🔑', + ], + [ + 'id' => 'prautoblogger_cloudflare_account_id', + 'label' => __( 'Cloudflare Account ID', 'prautoblogger' ), + 'type' => 'text', + 'section' => 'prautoblogger_images', + 'default' => '', + 'description' => __( 'The Account ID shown on your Cloudflare dashboard sidebar. Used to build the Workers AI URL.', 'prautoblogger' ), + ], + [ + 'id' => 'prautoblogger_image_model', + 'label' => __( 'Image Model', 'prautoblogger' ), + 'type' => 'select', + 'section' => 'prautoblogger_images', + 'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, + 'options' => [ + 'flux-1-schnell' => __( 'FLUX.1 [schnell] — fast + cheap (default)', 'prautoblogger' ), + 'flux-1-dev' => __( 'FLUX.1 [dev] — higher quality, ~4× cost', 'prautoblogger' ), + ], + 'description' => __( 'Schnell is the normal choice. Only switch to [dev] when a specific post needs higher quality.', 'prautoblogger' ), + 'badge' => __( 'Low cost', 'prautoblogger' ), + ], + [ + 'id' => 'prautoblogger_image_style_suffix', + 'label' => __( 'Style Suffix', 'prautoblogger' ), + 'type' => 'textarea', + 'section' => 'prautoblogger_images', + 'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX, + 'description' => __( 'Appended to every image prompt. Controls the visual look across all placements. Changing this mid-run will cause visible style drift — prefer changing during quiet periods.', 'prautoblogger' ), + ], ]; } } diff --git a/includes/providers/class-cloudflare-image-pricing.php b/includes/providers/class-cloudflare-image-pricing.php new file mode 100644 index 0000000..c7e65dd --- /dev/null +++ b/includes/providers/class-cloudflare-image-pricing.php @@ -0,0 +1,78 @@ + + */ + private const MODEL_ALIAS_MAP = [ + 'flux-1-schnell' => '@cf/black-forest-labs/flux-1-schnell', + 'flux-1-dev' => '@cf/black-forest-labs/flux-1-dev', + ]; + + /** + * 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. Schnell is the baseline; + * [dev] is a ~4x multiplier. 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; + if ( false !== strpos( $model, 'flux-1-dev' ) ) { + $rate *= self::COST_MULTIPLIER_DEV; + } + return round( $mp * $rate, 6 ); + } +} diff --git a/includes/providers/class-cloudflare-image-provider.php b/includes/providers/class-cloudflare-image-provider.php new file mode 100644 index 0000000..734dead --- /dev/null +++ b/includes/providers/class-cloudflare-image-provider.php @@ -0,0 +1,271 @@ +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 { + $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 ); + 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 ) + ) + ); + } + + /** + * 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 new file mode 100644 index 0000000..e4a05a2 --- /dev/null +++ b/includes/providers/class-cloudflare-image-support.php @@ -0,0 +1,101 @@ +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' + ); + } +} diff --git a/includes/providers/class-cloudflare-image-validator.php b/includes/providers/class-cloudflare-image-validator.php new file mode 100644 index 0000000..764c3f0 --- /dev/null +++ b/includes/providers/class-cloudflare-image-validator.php @@ -0,0 +1,116 @@ +err( + 'account_id_empty', + __( 'Cloudflare account ID is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' ) + ); + } + + $encrypted = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' ); + $api_token = '' === $encrypted ? '' : (string) PRAutoBlogger_Encryption::decrypt( $encrypted ); + 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/includes/providers/interface-image-provider.php b/includes/providers/interface-image-provider.php new file mode 100644 index 0000000..33701c8 --- /dev/null +++ b/includes/providers/interface-image-provider.php @@ -0,0 +1,100 @@ += 64, <= 2048). + * @param int $height Target height in pixels (>= 64, <= 2048). + * @param array{ + * seed?: int, + * steps?: int, + * model?: string, + * } $options Optional tuning. `seed` lets the caller ask for visually + * coherent variants across placements. `steps` trades quality + * for cost (default model-dependent). `model` picks a specific + * model identifier; implementations may ignore values they + * don't support. + * + * @return array{ + * bytes: string, + * mime_type: string, + * width: int, + * height: int, + * model: string, + * seed: ?int, + * cost_usd: float, + * latency_ms: int, + * } Raw image bytes (PNG or JPEG) plus metadata. Metadata is used by the + * caller to write post meta and to bill against the plugin's cost budget. + * + * @throws \RuntimeException On API error after retries exhausted, or on + * an invalid response shape. + * @throws \InvalidArgumentException If dimensions are out of range. + */ + public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array; + + /** + * Estimate the USD cost of a single generation call before making it. + * + * Used by the cost tracker to pre-check budget and to show an expected + * spend figure in the dry-run preview. + * + * @param int $width Target width in pixels. + * @param int $height Target height in pixels. + * @param array $options Same shape as generate_image() options. + * + * @return float Estimated cost in USD for a single image at the given dimensions. + */ + public function estimate_cost( int $width, int $height, array $options = [] ): float; + + /** + * Machine-readable provider identifier used in logs and settings. + * + * @return string Lowercase identifier, e.g. 'cloudflare_workers_ai'. + */ + public function get_provider_name(): string; + + /** + * Verify the provider credentials are present and the API is reachable. + * + * Used by the admin "Test Connections" action. Implementations should make + * a lightweight request (e.g. a models list or token verify endpoint) — NOT + * generate a real image. + * + * Side effects: HTTP request, logs the result. + * + * @return array{status: string, message: string, debug?: string} + * status is 'ok' or 'error'; message is human-readable for the + * admin UI; debug is optional diagnostic detail for logs. + */ + public function validate_credentials_detailed(): array; +} diff --git a/prautoblogger.php b/prautoblogger.php index 229ceda..5f7fd6a 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -53,6 +53,19 @@ define( 'PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL', 'google/gemini-2.5-flash-lite' ) define( 'PRAUTOBLOGGER_DEFAULT_WRITING_MODEL', 'google/gemini-2.5-flash-lite' ); define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' ); +// Image generation defaults (Cloudflare Workers AI, FLUX family). +// Short alias; the Cloudflare image pricing helper expands it to the full +// `@cf/black-forest-labs/...` model id when building the Workers AI URL. +define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'flux-1-schnell' ); + +// CEO-approved image style suffix (decision locked 2026-04-15). Users may +// override via Settings → Images → Style Suffix; this constant is the +// fallback the setting reads on first install. +define( + 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', + 'Style: a screengrab from a 1995 late-night cable TV infomercial, oversaturated warm colors, VHS tape noise and scan lines, a bright yellow starburst graphic in the top right corner reading NEW, cheap studio lighting, dated 4:3 television framing, deadpan earnest expression, faint lens distortion, tacky low-budget commercial aesthetic.' +); + /* |-------------------------------------------------------------------------- | Autoloader diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 596b218..97a4152 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -37,7 +37,9 @@ if ( ! defined( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS' ) ) { define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 30 ); } if ( ! defined( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS' ) ) { - define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 1 ); + // Zero in the test bootstrap so retry-path tests don't spend real wall + // time in sleep(). Production default is 2 (see prautoblogger.php). + define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 0 ); } // Default model slugs used by Publisher, ChiefEditor, Activator, settings UI. diff --git a/tests/unit/Providers/CloudflareImageProviderTest.php b/tests/unit/Providers/CloudflareImageProviderTest.php new file mode 100644 index 0000000..b6e8b0e --- /dev/null +++ b/tests/unit/Providers/CloudflareImageProviderTest.php @@ -0,0 +1,432 @@ +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 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'] ); + } +}