From fcc61c0cb826426fb79356ad42065095e9cbf111 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 20:49:54 +0700 Subject: [PATCH 1/2] refactor: Split OpenRouter provider to bring files under 300-line cap Extracted credential validation, request building, response parsing, and configuration helpers from the 464-line OpenRouter provider into separate, focused classes. This follows the pattern established by the Cloudflare image provider and brings all files under the project's 300-line limit. Files created: - class-open-router-validator.php (152 lines): Handles credential validation with HTTP probes and diagnostic messaging. Delegates from the provider. - class-open-router-config.php (84 lines): Centralizes admin config resolution for API base URL and cache TTL settings. Used by both provider and validator. - class-open-router-request-builder.php (79 lines): Encapsulates request header building and cURL auth filter registration for hosting environments that strip Authorization headers. - class-open-router-response-parser.php (87 lines): Handles response parsing, error classification, and retry-decision logic. - Tests: OpenRouterValidatorTest.php with comprehensive credential checks. Provider shrinks from 464 to 278 lines. All classes are under 300 lines. Dependencies remain clean: validator and config are independent, request-builder and response-parser have no circular imports. Co-Authored-By: Claude Opus 4.6 --- .../providers/class-open-router-config.php | 84 ++++++ .../providers/class-open-router-provider.php | 242 ++---------------- .../class-open-router-request-builder.php | 79 ++++++ .../class-open-router-response-parser.php | 87 +++++++ .../providers/class-open-router-validator.php | 152 +++++++++++ .../Providers/OpenRouterValidatorTest.php | 196 ++++++++++++++ 6 files changed, 626 insertions(+), 214 deletions(-) create mode 100644 includes/providers/class-open-router-config.php create mode 100644 includes/providers/class-open-router-request-builder.php create mode 100644 includes/providers/class-open-router-response-parser.php create mode 100644 includes/providers/class-open-router-validator.php create mode 100644 tests/unit/Providers/OpenRouterValidatorTest.php diff --git a/includes/providers/class-open-router-config.php b/includes/providers/class-open-router-config.php new file mode 100644 index 0000000..72b191c --- /dev/null +++ b/includes/providers/class-open-router-config.php @@ -0,0 +1,84 @@ + 0 ? $ttl : 0; + } + + /** + * Check if routing through Cloudflare AI Gateway vs. direct-to-OpenRouter. + * + * @return bool True if a gateway URL is configured. + */ + public function is_via_gateway(): bool { + return self::DEFAULT_API_BASE_URL !== $this->get_api_base_url(); + } + + /** + * Get the default OpenRouter API base URL constant. + * + * @return string + */ + public static function get_default_api_base_url(): string { + return self::DEFAULT_API_BASE_URL; + } +} diff --git a/includes/providers/class-open-router-provider.php b/includes/providers/class-open-router-provider.php index 88faa10..f16ed1a 100644 --- a/includes/providers/class-open-router-provider.php +++ b/includes/providers/class-open-router-provider.php @@ -11,60 +11,17 @@ declare(strict_types=1); * Triggered by: Content_Analyzer, Content_Generator, Chief_Editor, Metrics_Collector. * Dependencies: PRAutoBlogger_Encryption (for API key decryption), wp_remote_post(), PRAutoBlogger_OpenRouter_Pricing. * - * @see interface-llm-provider.php — Interface this class implements. - * @see class-open-router-pricing.php — Pricing and model list lookups. - * @see class-cost-tracker.php — Called after every request to log token usage. - * @see ARCHITECTURE.md — Data flow diagram showing where this fits. + * @see interface-llm-provider.php — Interface this class implements. + * @see class-open-router-config.php — Config helpers (base URL, cache TTL). + * @see class-open-router-pricing.php — Pricing and model list lookups. + * @see class-open-router-validator.php — Credential validation (delegated). + * @see class-open-router-request-builder.php — Request header building + cURL filter. + * @see class-open-router-response-parser.php — Response parsing + error classification. + * @see class-cost-tracker.php — Called after every request to log token usage. + * @see ARCHITECTURE.md — Data flow diagram showing where this fits. */ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_Interface { - /** - * Default direct-to-OpenRouter endpoint. Used when no Cloudflare AI Gateway - * URL is configured. The active endpoint is resolved per-request via - * get_api_base_url() so admins can flip to an AI Gateway proxy without code. - */ - private const DEFAULT_API_BASE_URL = 'https://openrouter.ai/api/v1'; - - /** - * Resolve the API base URL. - * - * If an admin has configured a Cloudflare AI Gateway URL in settings, - * we route through that instead of hitting OpenRouter directly. The - * gateway is a transparent OpenAI/OpenRouter-compatible proxy that - * adds caching, cost logging, rate-limiting, and provider fallback - * — see ARCHITECTURE.md "External API Integrations". - * - * Expected gateway URL shape: - * https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter - * - * @return string Base URL with no trailing slash. - */ - private function get_api_base_url(): string { - $override = trim( (string) get_option( 'prautoblogger_ai_gateway_base_url', '' ) ); - if ( '' === $override ) { - return self::DEFAULT_API_BASE_URL; - } - // Only accept https to avoid plaintext-auth regressions. - if ( 0 !== stripos( $override, 'https://' ) ) { - return self::DEFAULT_API_BASE_URL; - } - return rtrim( $override, '/' ); - } - - /** - * Cache TTL (seconds) for AI Gateway response caching. - * - * Cloudflare honours the `cf-aig-cache-ttl` request header and serves - * cached responses for identical payloads within the TTL window. Only - * meaningful when a gateway base URL is configured; harmless otherwise. - * - * @return int Non-negative integer seconds; 0 disables caching. - */ - private function get_cache_ttl_seconds(): int { - $ttl = (int) get_option( 'prautoblogger_ai_gateway_cache_ttl', 0 ); - return $ttl > 0 ? $ttl : 0; - } - /** * Send a chat completion request to OpenRouter. * @@ -133,49 +90,19 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In $last_error = ''; - $base_url = $this->get_api_base_url(); - $base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST ); - $cache_ttl = $this->get_cache_ttl_seconds(); - $via_gateway = self::DEFAULT_API_BASE_URL !== $base_url; + $config = new PRAutoBlogger_OpenRouter_Config(); + $base_url = $config->get_api_base_url(); + $base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST ); + $cache_ttl = $config->get_cache_ttl_seconds(); + $via_gateway = $config->is_via_gateway(); - // Build headers once so the wp_remote_post call and the cURL belt-and- - // suspenders filter stay in lockstep. The cf-aig-* headers are only - // meaningful when going through a Cloudflare AI Gateway; they are - // ignored by direct OpenRouter calls, so sending them unconditionally - // when a gateway is configured is safe and keeps the code branch-free. - $request_headers = [ - 'Authorization' => 'Bearer ' . $api_key, - 'Content-Type' => 'application/json', - 'HTTP-Referer' => home_url(), - 'X-Title' => 'PRAutoBlogger WordPress Plugin', - ]; - if ( $via_gateway && $cache_ttl > 0 ) { - $request_headers['cf-aig-cache-ttl'] = (string) $cache_ttl; - } - - // Belt-and-suspenders: inject Authorization header at cURL level. - // Some hosting environments (Hostinger, certain proxies) strip the - // Authorization header from wp_remote_post's 'headers' array before - // the request is sent. The http_api_curl action fires after WordPress - // configures the cURL handle but before curl_exec — re-setting - // CURLOPT_HTTPHEADER here ensures the header reaches the upstream - // (either OpenRouter directly or the Cloudflare AI Gateway proxy). - $curl_auth_filter = function ( $handle, $parsed_args, $url ) use ( $request_headers, $base_host ): void { - // Scope to the configured upstream host only — never leak auth - // into unrelated outbound requests made elsewhere in WordPress. - if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) { - return; - } - $curl_headers = []; - foreach ( $request_headers as $name => $value ) { - $curl_headers[] = $name . ': ' . $value; - } - // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt - curl_setopt( $handle, CURLOPT_HTTPHEADER, $curl_headers ); - }; - add_action( 'http_api_curl', $curl_auth_filter, 99, 3 ); + $builder = new PRAutoBlogger_OpenRouter_Request_Builder(); + $request_headers = $builder->build_headers( $api_key, $via_gateway, $cache_ttl ); + $curl_auth_filter = $builder->register_curl_auth_filter( $request_headers, $base_host ); try { + $parser = new PRAutoBlogger_OpenRouter_Response_Parser(); + for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) { $response = wp_remote_post( $base_url . '/chat/completions', @@ -205,7 +132,7 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In $data = json_decode( $body_raw, true ); // Rate limited (429) or server error (5xx) — retry with backoff. - if ( 429 === $status_code || $status_code >= 500 ) { + if ( $parser->is_retryable( $status_code ) ) { $last_error = sprintf( 'HTTP %d: %s', $status_code, $body_raw ); PRAutoBlogger_Logger::instance()->warning( sprintf( 'OpenRouter HTTP %d (attempt %d/%d): %s', $status_code, $attempt, PRAUTOBLOGGER_MAX_RETRIES, substr( $body_raw, 0, 500 ) ), @@ -225,9 +152,7 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In // Client error — don't retry, fail immediately. if ( $status_code >= 400 ) { - $error_msg = isset( $data['error']['message'] ) - ? $data['error']['message'] - : 'HTTP ' . $status_code; + $error_msg = $parser->get_error_message( $data, $status_code ); // Log detailed diagnostic info for auth errors to aid debugging. if ( 401 === $status_code || 403 === $status_code ) { @@ -254,22 +179,7 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In } // Success — parse response. - if ( ! isset( $data['choices'][0]['message']['content'] ) ) { - throw new \RuntimeException( - __( 'OpenRouter returned unexpected response format.', 'prautoblogger' ) - ); - } - - $usage = $data['usage'] ?? []; - - return [ - 'content' => $data['choices'][0]['message']['content'], - 'model' => $data['model'] ?? $model, - 'prompt_tokens' => (int) ( $usage['prompt_tokens'] ?? 0 ), - 'completion_tokens' => (int) ( $usage['completion_tokens'] ?? 0 ), - 'total_tokens' => (int) ( $usage['total_tokens'] ?? 0 ), - 'finish_reason' => $data['choices'][0]['finish_reason'] ?? 'unknown', - ]; + return $parser->parse_success( $data, $model ); } // All retries exhausted. @@ -327,9 +237,9 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In /** * Validate that the OpenRouter API key is configured and working. * - * Makes a lightweight request to the /auth/key endpoint. + * Delegates to PRAutoBlogger_OpenRouter_Validator for the actual checks. * - * Side effects: HTTP request to OpenRouter, logs diagnostic info. + * Side effects: HTTP request to OpenRouter (via validator), logs diagnostic info. * * @return bool */ @@ -341,112 +251,16 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In /** * Validate credentials with detailed diagnostic info. * - * Returns an array with status ('ok' or 'error') and a human-readable - * message explaining what went wrong if validation fails. + * Delegates to PRAutoBlogger_OpenRouter_Validator which returns an array + * with status ('ok' or 'error') and a human-readable message explaining + * what went wrong if validation fails. * - * Side effects: HTTP request to OpenRouter, logs diagnostic info. + * Side effects: HTTP request to OpenRouter (via validator), logs diagnostic info. * * @return array{status: string, message: string, debug?: string} */ public function validate_credentials_detailed(): array { - $encrypted = get_option( 'prautoblogger_openrouter_api_key', '' ); - if ( '' === $encrypted ) { - return [ - 'status' => 'error', - 'message' => __( 'No API key saved. Enter your OpenRouter key in settings.', 'prautoblogger' ), - 'debug' => 'option_empty', - ]; - } - - $api_key = PRAutoBlogger_Encryption::decrypt( $encrypted ); - if ( '' === $api_key ) { - return [ - 'status' => 'error', - 'message' => __( 'API key decryption failed. Re-enter your key.', 'prautoblogger' ), - 'debug' => 'decrypt_failed:encrypted_len=' . strlen( $encrypted ), - ]; - } - - // Sanity check: OpenRouter keys typically start with "sk-or-". - $key_prefix = substr( $api_key, 0, 6 ); - $key_len = strlen( $api_key ); - - if ( 0 !== strpos( $api_key, 'sk-or-' ) ) { - PRAutoBlogger_Logger::instance()->error( - sprintf( - 'API key format invalid (prefix="%s", len=%d). Likely salt changed — re-enter key.', - $key_prefix, - $key_len - ), - 'openrouter' - ); - return [ - 'status' => 'error', - 'message' => __( 'API key appears corrupted (decryption produced invalid data). Your WordPress auth salt may have changed. Please re-enter your OpenRouter API key in settings.', 'prautoblogger' ), - 'debug' => sprintf( 'bad_format:prefix=%s,len=%d', $key_prefix, $key_len ), - ]; - } - - // Belt-and-suspenders: inject Authorization at cURL level (see send_chat_completion). - $base_url = $this->get_api_base_url(); - $base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST ); - $auth_header_value = 'Bearer ' . $api_key; - $curl_auth_filter_cred = function ( $handle, $parsed_args, $url ) use ( $auth_header_value, $base_host ): void { - if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) { - return; - } - // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt - curl_setopt( $handle, CURLOPT_HTTPHEADER, [ - 'Authorization: ' . $auth_header_value, - ] ); - }; - add_action( 'http_api_curl', $curl_auth_filter_cred, 99, 3 ); - - $response = wp_remote_get( - $base_url . '/auth/key', - [ - 'timeout' => 15, - 'headers' => [ - 'Authorization' => 'Bearer ' . $api_key, - ], - ] - ); - - remove_action( 'http_api_curl', $curl_auth_filter_cred, 99 ); - - if ( is_wp_error( $response ) ) { - $err_msg = $response->get_error_message(); - PRAutoBlogger_Logger::instance()->error( - sprintf( 'OpenRouter credential check wp_error: %s (key_prefix=%s, key_len=%d)', $err_msg, $key_prefix, $key_len ), - 'openrouter' - ); - return [ - 'status' => 'error', - 'message' => sprintf( __( 'Network error reaching OpenRouter: %s', 'prautoblogger' ), $err_msg ), - 'debug' => 'wp_error:' . $err_msg, - ]; - } - - $status_code = wp_remote_retrieve_response_code( $response ); - $body_raw = wp_remote_retrieve_body( $response ); - - if ( 200 === $status_code ) { - return [ - 'status' => 'ok', - 'message' => __( 'OpenRouter connected.', 'prautoblogger' ), - ]; - } - - PRAutoBlogger_Logger::instance()->warning( - sprintf( 'OpenRouter credential check HTTP %d (key_prefix=%s, key_len=%d): %s', $status_code, $key_prefix, $key_len, substr( $body_raw, 0, 300 ) ), - 'openrouter' - ); - - return [ - 'status' => 'error', - 'message' => sprintf( __( 'OpenRouter returned HTTP %d. %s', 'prautoblogger' ), $status_code, substr( $body_raw, 0, 200 ) ), - 'debug' => sprintf( 'http_%d:key_prefix=%s,key_len=%d', $status_code, $key_prefix, $key_len ), - ]; + return ( new PRAutoBlogger_OpenRouter_Validator() )->run(); } /** diff --git a/includes/providers/class-open-router-request-builder.php b/includes/providers/class-open-router-request-builder.php new file mode 100644 index 0000000..09116c5 --- /dev/null +++ b/includes/providers/class-open-router-request-builder.php @@ -0,0 +1,79 @@ + HTTP headers ready for wp_remote_post(). + */ + public function build_headers( string $api_key, bool $via_gateway, int $cache_ttl ): array { + $headers = [ + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json', + 'HTTP-Referer' => home_url(), + 'X-Title' => 'PRAutoBlogger WordPress Plugin', + ]; + + if ( $via_gateway && $cache_ttl > 0 ) { + $headers['cf-aig-cache-ttl'] = (string) $cache_ttl; + } + + return $headers; + } + + /** + * Register a cURL filter to inject Authorization header. + * + * Some hosting environments (Hostinger, certain proxies) strip the + * Authorization header from wp_remote_post's 'headers' array before + * the request is sent. The http_api_curl action fires after WordPress + * configures the cURL handle but before curl_exec — setting + * CURLOPT_HTTPHEADER here ensures the header reaches the upstream. + * + * Side effects: Adds an http_api_curl filter (caller must remove). + * + * @param array $request_headers Request headers (includes Authorization). + * @param string $base_host Upstream host (scopes the filter to avoid leaking auth). + * + * @return callable The filter function (for later removal via remove_action). + */ + public function register_curl_auth_filter( array $request_headers, string $base_host ): callable { + $curl_auth_filter = function ( $handle, $parsed_args, $url ) use ( $request_headers, $base_host ): void { + // Scope to the configured upstream host only — never leak auth + // into unrelated outbound requests made elsewhere in WordPress. + if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) { + return; + } + $curl_headers = []; + foreach ( $request_headers as $name => $value ) { + $curl_headers[] = $name . ': ' . $value; + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt + curl_setopt( $handle, CURLOPT_HTTPHEADER, $curl_headers ); + }; + add_action( 'http_api_curl', $curl_auth_filter, 99, 3 ); + return $curl_auth_filter; + } +} diff --git a/includes/providers/class-open-router-response-parser.php b/includes/providers/class-open-router-response-parser.php new file mode 100644 index 0000000..40e224c --- /dev/null +++ b/includes/providers/class-open-router-response-parser.php @@ -0,0 +1,87 @@ += 500; + } + + /** + * Parse a successful OpenRouter response. + * + * Validates the response contains the required fields and extracts + * the completion content and token usage. + * + * @param array $data Decoded JSON response from OpenRouter. + * @param string $model The model that was requested (fallback if not in response). + * + * @return array{ + * content: string, + * model: string, + * prompt_tokens: int, + * completion_tokens: int, + * total_tokens: int, + * finish_reason: string, + * } + * + * @throws \RuntimeException If response structure is invalid. + */ + public function parse_success( array $data, string $model ): array { + if ( ! isset( $data['choices'][0]['message']['content'] ) ) { + throw new \RuntimeException( + __( 'OpenRouter returned unexpected response format.', 'prautoblogger' ) + ); + } + + $usage = $data['usage'] ?? []; + + return [ + 'content' => $data['choices'][0]['message']['content'], + 'model' => $data['model'] ?? $model, + 'prompt_tokens' => (int) ( $usage['prompt_tokens'] ?? 0 ), + 'completion_tokens' => (int) ( $usage['completion_tokens'] ?? 0 ), + 'total_tokens' => (int) ( $usage['total_tokens'] ?? 0 ), + 'finish_reason' => $data['choices'][0]['finish_reason'] ?? 'unknown', + ]; + } + + /** + * Extract the error message from a failed response. + * + * Prefers the `error.message` field if present; falls back to the + * HTTP status code as a string. + * + * @param array $data Decoded JSON response (may be empty or malformed). + * @param int $status HTTP status code. + * + * @return string Human-readable error message. + */ + public function get_error_message( array $data, int $status ): string { + return isset( $data['error']['message'] ) + ? $data['error']['message'] + : 'HTTP ' . $status; + } +} diff --git a/includes/providers/class-open-router-validator.php b/includes/providers/class-open-router-validator.php new file mode 100644 index 0000000..c10c478 --- /dev/null +++ b/includes/providers/class-open-router-validator.php @@ -0,0 +1,152 @@ +err( + 'option_empty', + __( 'No API key saved. Enter your OpenRouter key in settings.', 'prautoblogger' ) + ); + } + + $api_key = PRAutoBlogger_Encryption::decrypt( $encrypted ); + if ( '' === $api_key ) { + return $this->err( + 'decrypt_failed:encrypted_len=' . strlen( $encrypted ), + __( 'API key decryption failed. Re-enter your key.', 'prautoblogger' ) + ); + } + + // Sanity check: OpenRouter keys typically start with "sk-or-". + $key_prefix = substr( $api_key, 0, 6 ); + $key_len = strlen( $api_key ); + + if ( 0 !== strpos( $api_key, 'sk-or-' ) ) { + PRAutoBlogger_Logger::instance()->error( + sprintf( + 'API key format invalid (prefix="%s", len=%d). Likely salt changed — re-enter key.', + $key_prefix, + $key_len + ), + 'openrouter' + ); + return $this->err( + sprintf( 'bad_format:prefix=%s,len=%d', $key_prefix, $key_len ), + __( 'API key appears corrupted (decryption produced invalid data). Your WordPress auth salt may have changed. Please re-enter your OpenRouter API key in settings.', 'prautoblogger' ) + ); + } + + // Belt-and-suspenders: inject Authorization at cURL level (see send_chat_completion). + $config = new PRAutoBlogger_OpenRouter_Config(); + $base_url = $config->get_api_base_url(); + $base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST ); + $auth_header_value = 'Bearer ' . $api_key; + $curl_auth_filter_cred = function ( $handle, $parsed_args, $url ) use ( $auth_header_value, $base_host ): void { + if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) { + return; + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt + curl_setopt( $handle, CURLOPT_HTTPHEADER, [ + 'Authorization: ' . $auth_header_value, + ] ); + }; + add_action( 'http_api_curl', $curl_auth_filter_cred, 99, 3 ); + + $response = wp_remote_get( + $base_url . '/auth/key', + [ + 'timeout' => self::TIMEOUT_SECONDS, + 'headers' => [ + 'Authorization' => 'Bearer ' . $api_key, + ], + ] + ); + + remove_action( 'http_api_curl', $curl_auth_filter_cred, 99 ); + + if ( is_wp_error( $response ) ) { + $err_msg = $response->get_error_message(); + PRAutoBlogger_Logger::instance()->error( + sprintf( 'OpenRouter credential check wp_error: %s (key_prefix=%s, key_len=%d)', $err_msg, $key_prefix, $key_len ), + 'openrouter' + ); + return $this->err( + 'wp_error:' . $err_msg, + sprintf( + /* translators: %s: transport-layer error message. */ + esc_html__( 'Network error reaching OpenRouter: %s', 'prautoblogger' ), + esc_html( $err_msg ) + ) + ); + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 === $status_code ) { + return [ + 'status' => 'ok', + 'message' => __( 'OpenRouter connected.', 'prautoblogger' ), + ]; + } + + $body_raw = wp_remote_retrieve_body( $response ); + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'OpenRouter credential check HTTP %d (key_prefix=%s, key_len=%d): %s', $status_code, $key_prefix, $key_len, substr( $body_raw, 0, 300 ) ), + 'openrouter' + ); + + return $this->err( + sprintf( 'http_%d:key_prefix=%s,key_len=%d', $status_code, $key_prefix, $key_len ), + sprintf( + /* translators: %1$d: HTTP status, %2$s: response body. */ + esc_html__( 'OpenRouter returned HTTP %1$d. %2$s', 'prautoblogger' ), + $status_code, + esc_html( substr( $body_raw, 0, 200 ) ) + ) + ); + } + + + /** + * @return array{status: string, message: string, debug: string} + */ + private function err( string $debug, string $message ): array { + return [ + 'status' => 'error', + 'message' => $message, + 'debug' => $debug, + ]; + } +} diff --git a/tests/unit/Providers/OpenRouterValidatorTest.php b/tests/unit/Providers/OpenRouterValidatorTest.php new file mode 100644 index 0000000..f7f4bf7 --- /dev/null +++ b/tests/unit/Providers/OpenRouterValidatorTest.php @@ -0,0 +1,196 @@ +stub_get_option( [ + 'prautoblogger_openrouter_api_key' => '', + 'prautoblogger_ai_gateway_base_url' => '', + 'prautoblogger_ai_gateway_cache_ttl' => 0, + 'prautoblogger_log_level' => 'info', + ] ); + + // Stub wp_salt for encryption. + Functions\when( 'wp_salt' )->justReturn( 'test_salt_key_for_unit_tests' ); + + // Stub HTTP functions. + Functions\when( 'wp_remote_get' )->justReturn( [ + 'body' => '{}', + 'response' => [ 'code' => 200 ], + ] ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{}' ); + Functions\when( 'is_wp_error' )->justReturn( false ); + } + + /** + * Test validator can be instantiated. + */ + public function test_validator_instantiation(): void { + $validator = new \PRAutoBlogger_OpenRouter_Validator(); + $this->assertInstanceOf( \PRAutoBlogger_OpenRouter_Validator::class, $validator ); + } + + /** + * Test run() returns array with expected keys. + */ + public function test_run_returns_array_with_status(): void { + $validator = new \PRAutoBlogger_OpenRouter_Validator(); + $result = $validator->run(); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'status', $result ); + $this->assertArrayHasKey( 'message', $result ); + } + + /** + * Test validation fails when no API key is configured. + */ + public function test_run_fails_with_no_api_key(): void { + $validator = new \PRAutoBlogger_OpenRouter_Validator(); + $result = $validator->run(); + + $this->assertSame( 'error', $result['status'] ); + $this->assertStringContainsString( 'No API key', $result['message'] ); + } + + /** + * Test validation succeeds with a valid decrypted key. + */ + public function test_run_succeeds_with_valid_key(): void { + // Round-trip through encryption to get a valid ciphertext. + $plaintext = 'sk-or-test-key-1234567890'; + $ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext ); + + $this->stub_get_option( [ + 'prautoblogger_openrouter_api_key' => $ciphertext, + 'prautoblogger_ai_gateway_base_url' => '', + 'prautoblogger_ai_gateway_cache_ttl' => 0, + 'prautoblogger_log_level' => 'info', + ] ); + + $validator = new \PRAutoBlogger_OpenRouter_Validator(); + $result = $validator->run(); + + $this->assertSame( 'ok', $result['status'] ); + $this->assertStringContainsString( 'connected', strtolower( $result['message'] ) ); + } + + /** + * Test validation fails when decryption fails. + */ + public function test_run_fails_with_bad_ciphertext(): void { + // Stub get_option to return invalid ciphertext. + $this->stub_get_option( [ + 'prautoblogger_openrouter_api_key' => 'corrupted_base64_data_that_is_not_valid', + 'prautoblogger_ai_gateway_base_url' => '', + 'prautoblogger_ai_gateway_cache_ttl' => 0, + 'prautoblogger_log_level' => 'info', + ] ); + + $validator = new \PRAutoBlogger_OpenRouter_Validator(); + $result = $validator->run(); + + $this->assertSame( 'error', $result['status'] ); + $this->assertStringContainsString( 'decryption', strtolower( $result['message'] ) ); + } + + /** + * Test validation fails when decrypted key has wrong format. + */ + public function test_run_fails_with_bad_key_format(): void { + // Encrypt a key that doesn't start with "sk-or-". + $plaintext = 'invalid-key-format'; + $ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext ); + + $this->stub_get_option( [ + 'prautoblogger_openrouter_api_key' => $ciphertext, + 'prautoblogger_ai_gateway_base_url' => '', + 'prautoblogger_ai_gateway_cache_ttl' => 0, + 'prautoblogger_log_level' => 'info', + ] ); + + $validator = new \PRAutoBlogger_OpenRouter_Validator(); + $result = $validator->run(); + + $this->assertSame( 'error', $result['status'] ); + $this->assertStringContainsString( 'corrupted', strtolower( $result['message'] ) ); + } + + /** + * Test validation fails when HTTP request fails. + */ + public function test_run_fails_with_wp_error(): void { + $plaintext = 'sk-or-test-key-1234567890'; + $ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext ); + + $this->stub_get_option( [ + 'prautoblogger_openrouter_api_key' => $ciphertext, + 'prautoblogger_ai_gateway_base_url' => '', + 'prautoblogger_ai_gateway_cache_ttl' => 0, + 'prautoblogger_log_level' => 'info', + ] ); + + // Stub wp_remote_get to return an error. + Functions\when( 'is_wp_error' )->justReturn( true ); + Functions\when( 'wp_remote_get' )->alias( + function () { + $error = new \WP_Error( 'http_error', 'Connection refused' ); + return $error; + } + ); + + $validator = new \PRAutoBlogger_OpenRouter_Validator(); + $result = $validator->run(); + + $this->assertSame( 'error', $result['status'] ); + $this->assertStringContainsString( 'Network error', $result['message'] ); + } + + /** + * Test validation fails when API returns non-200 status. + */ + public function test_run_fails_with_http_error(): void { + $plaintext = 'sk-or-test-key-1234567890'; + $ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext ); + + $this->stub_get_option( [ + 'prautoblogger_openrouter_api_key' => $ciphertext, + 'prautoblogger_ai_gateway_base_url' => '', + 'prautoblogger_ai_gateway_cache_ttl' => 0, + 'prautoblogger_log_level' => 'info', + ] ); + + // Stub to return 401 error. + Functions\when( 'is_wp_error' )->justReturn( false ); + Functions\when( 'wp_remote_get' )->justReturn( [ + 'body' => '{"error":{"message":"Unauthorized"}}', + 'response' => [ 'code' => 401 ], + ] ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"error":{"message":"Unauthorized"}}' ); + + $validator = new \PRAutoBlogger_OpenRouter_Validator(); + $result = $validator->run(); + + $this->assertSame( 'error', $result['status'] ); + $this->assertStringContainsString( 'HTTP 401', $result['message'] ); + } +} From d31d378ec1642a525d5fa7ee27aa2dc1074b4860 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 20:55:03 +0700 Subject: [PATCH 2/2] fix(tests): add wp_parse_url mock to OpenRouter tests Both the provider and validator call wp_parse_url() for host extraction. Tests were missing the Brain Monkey stub. Co-Authored-By: Claude Opus 4.6 --- tests/unit/Providers/OpenRouterProviderTest.php | 3 +++ tests/unit/Providers/OpenRouterValidatorTest.php | 3 +++ 2 files changed, 6 insertions(+) diff --git a/tests/unit/Providers/OpenRouterProviderTest.php b/tests/unit/Providers/OpenRouterProviderTest.php index b650bd2..0f9d301 100644 --- a/tests/unit/Providers/OpenRouterProviderTest.php +++ b/tests/unit/Providers/OpenRouterProviderTest.php @@ -48,6 +48,9 @@ class OpenRouterProviderTest extends BaseTestCase { ); Functions\when( 'set_transient' )->justReturn( true ); + // Stub URL parsing. + Functions\when( 'wp_parse_url' )->alias( 'parse_url' ); + // Stub HTTP functions for API calls. Functions\when( 'wp_remote_post' )->justReturn( [ 'body' => '{"choices":[{"message":{"content":"test"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}', diff --git a/tests/unit/Providers/OpenRouterValidatorTest.php b/tests/unit/Providers/OpenRouterValidatorTest.php index f7f4bf7..540e21f 100644 --- a/tests/unit/Providers/OpenRouterValidatorTest.php +++ b/tests/unit/Providers/OpenRouterValidatorTest.php @@ -30,6 +30,9 @@ class OpenRouterValidatorTest extends BaseTestCase { // Stub wp_salt for encryption. Functions\when( 'wp_salt' )->justReturn( 'test_salt_key_for_unit_tests' ); + // Stub URL parsing. + Functions\when( 'wp_parse_url' )->alias( 'parse_url' ); + // Stub HTTP functions. Functions\when( 'wp_remote_get' )->justReturn( [ 'body' => '{}',