fix: migrate Runware catalog to modelSearch; failure cooldown (v0.18.3 R4)
Runware retired taskType:models; all 42 daily catalog 401s come from this deprecated endpoint. Migrated to auth+modelSearch pair per Runware's current API. Paginator fetches up to 5 pages x 100 = 500 models, filters client-side to io:text-to-image in capabilities[]. Uses air identifier as model id. Cache key bumped to v2 (prautoblogger_runware_model_cache_v2) to invalidate v1 rows that used old taskType/requiresImage shape. Failure cooldown: sync() records prautoblogger_runware_catalog_last_failure_at on every error path. get_models() gates re-trigger via is_in_failure_cooldown() which reads prautoblogger_runware_catalog_failure_cooldown_seconds (default 3600s). Success clears the timestamp. Caps cascade to ~24 errors/day. 300-line rule: fetching/normalization extracted to new companion class PRAutoBlogger_Runware_Catalog_Fetcher (class-runware-catalog-fetcher.php, 205 lines). Catalog class: 261 lines. Autoloader picks up the new file. Tests: normalize_models against real modelSearch fixture (redacted key); cooldown behavior (failure->active->expired); sync clears timestamp; fallback list always served when cache empty. 328/328 PHPUnit green. Agent-Session: prab-v0183-20260612
This commit is contained in:
24
CHANGELOG.md
24
CHANGELOG.md
@@ -42,6 +42,30 @@ GA4-context SQL fix, manual-run resilience, and honest status on transient expir
|
|||||||
- **Generation_Lock**: new `get_acquired_at()` method returns the Unix timestamp when the
|
- **Generation_Lock**: new `get_acquired_at()` method returns the Unix timestamp when the
|
||||||
lock was set, used by R2b/R3 to determine lock age without touching the pipeline run table.
|
lock was set, used by R2b/R3 to determine lock age without touching the pipeline run table.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Runware catalog `modelSearch` migration**: `class-runware-model-catalog.php` migrated
|
||||||
|
from the retired `taskType:models` endpoint to the current `taskType:modelSearch` API.
|
||||||
|
Payload format: `[{taskType:authentication,apiKey:...},{taskType:modelSearch,
|
||||||
|
taskUUID:<uuid4>,offset:N,limit:N}]`. Response normalization updated for the new shape:
|
||||||
|
`air` identifier replaces `id`, `capabilities[]` array replaces `taskType`/`requiresImage`
|
||||||
|
(filter: entry must contain `"io:text-to-image"`), `comment` field maps to `description`.
|
||||||
|
Logic extracted to `class-runware-catalog-fetcher.php` companion (300-line rule). Cache
|
||||||
|
option key bumped to `prautoblogger_runware_model_cache_v2` to cleanly invalidate v1 rows.
|
||||||
|
- **Pagination for `modelSearch`**: fetcher paginates via `offset`/`limit` up to 5 pages of
|
||||||
|
100 results = 500 models inspected. Runware's catalog has 320k+ entries (mostly community
|
||||||
|
checkpoints); 5 pages reliably captures all curated text-to-image models. A page-cap
|
||||||
|
warning is logged if the cap is reached before exhausting `totalResults`. Client-side
|
||||||
|
`io:text-to-image` capability filter applied — no server-side filter is available.
|
||||||
|
- **Negative-result failure cooldown**: `sync()` records a failure timestamp in
|
||||||
|
`prautoblogger_runware_catalog_last_failure_at` on every error path. `get_models()`
|
||||||
|
gates re-trigger through `is_in_failure_cooldown()`, which respects a configurable
|
||||||
|
window from `prautoblogger_runware_catalog_failure_cooldown_seconds` (default 3600s / 1h,
|
||||||
|
never hardcoded at point of use). A successful sync clears the timestamp. This caps the
|
||||||
|
admin-page-load cascade (42 errors/day observed) to ~24/day even when the cache is absent.
|
||||||
|
- **Hardcoded fallback list preserved**: unchanged; carried prod through the entire
|
||||||
|
`taskType:models` outage. Tests verify it is always non-empty and correctly priced.
|
||||||
|
|
||||||
|
|
||||||
## [0.18.2] - 2026-06-12
|
## [0.18.2] - 2026-06-12
|
||||||
|
|
||||||
Four confirmed-live maintenance fixes (CTO thread `2026-06-pipeline-v2-phase1` seq 25).
|
Four confirmed-live maintenance fixes (CTO thread `2026-06-pipeline-v2-phase1` seq 25).
|
||||||
|
|||||||
211
includes/providers/class-runware-catalog-fetcher.php
Normal file
211
includes/providers/class-runware-catalog-fetcher.php
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Runware modelSearch API fetcher — handles pagination and response normalization
|
||||||
|
* for the Runware model catalog sync.
|
||||||
|
*
|
||||||
|
* Extracted from PRAutoBlogger_Runware_Model_Catalog for the 300-line rule.
|
||||||
|
* Provides two public methods consumed by the catalog class:
|
||||||
|
* - fetch_all_text_to_image(): paginates modelSearch and returns raw results.
|
||||||
|
* - normalize_models(): converts raw results to Image_Model_Registry shape.
|
||||||
|
*
|
||||||
|
* API note (2026-06-12): Runware retired taskType:models. The replacement is
|
||||||
|
* taskType:modelSearch — requires auth+modelSearch tasks, returns paginated
|
||||||
|
* results with `air` identifiers and `capabilities[]`. Text-to-image filtering
|
||||||
|
* is client-side (no server-side capability filter is available). The catalog
|
||||||
|
* has 320k+ entries (mostly community checkpoints); we cap at PAGE_CAP pages
|
||||||
|
* of PAGE_SIZE results to bound request cost while covering curated t2i models.
|
||||||
|
*
|
||||||
|
* @see class-runware-model-catalog.php — Caller; owns caching, cooldown, and fallback.
|
||||||
|
* @see class-image-model-registry.php — Consumes the normalized model shape.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Runware_Catalog_Fetcher {
|
||||||
|
|
||||||
|
private const RUNWARE_API_URL = 'https://api.runware.ai/v1';
|
||||||
|
private const HTTP_TIMEOUT_SEC = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum pages to fetch per sync cycle.
|
||||||
|
* 5 pages × 100 results = 500 models inspected.
|
||||||
|
* Curated Runware-native text-to-image models number ~50 in the first 500.
|
||||||
|
*/
|
||||||
|
private const PAGE_CAP = 5;
|
||||||
|
private const PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all text-to-image model results via paginated modelSearch calls.
|
||||||
|
*
|
||||||
|
* Issues up to PAGE_CAP requests against the Runware /v1 endpoint, each
|
||||||
|
* sending an auth task + a modelSearch task with offset/limit. Stops early
|
||||||
|
* when all available results have been fetched. Returns the raw result
|
||||||
|
* objects (unfiltered — normalize_models() applies the capability filter).
|
||||||
|
*
|
||||||
|
* @param string $api_key Decrypted Runware API key.
|
||||||
|
* @return array<int, array<string, mixed>> Raw modelSearch result objects.
|
||||||
|
* @throws \RuntimeException On HTTP error or malformed response for any page.
|
||||||
|
*/
|
||||||
|
public function fetch_all_text_to_image( string $api_key ): array {
|
||||||
|
$all_results = array();
|
||||||
|
$offset = 0;
|
||||||
|
$pages_fetched = 0;
|
||||||
|
$total = PHP_INT_MAX; // Set after first page.
|
||||||
|
|
||||||
|
do {
|
||||||
|
$page = $this->fetch_page( $api_key, $offset, self::PAGE_SIZE );
|
||||||
|
if ( empty( $page['results'] ) ) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$all_results = array_merge( $all_results, $page['results'] );
|
||||||
|
$total = (int) $page['totalResults'];
|
||||||
|
$offset += self::PAGE_SIZE;
|
||||||
|
++$pages_fetched;
|
||||||
|
|
||||||
|
if ( count( $all_results ) >= $total ) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} while ( $pages_fetched < self::PAGE_CAP );
|
||||||
|
|
||||||
|
if ( $pages_fetched >= self::PAGE_CAP && count( $all_results ) < $total ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf(
|
||||||
|
'Runware catalog: reached page cap (%d pages, %d models fetched of %d total). '
|
||||||
|
. 'Increase PAGE_CAP if curated models are being missed.',
|
||||||
|
self::PAGE_CAP,
|
||||||
|
count( $all_results ),
|
||||||
|
$total
|
||||||
|
),
|
||||||
|
'runware-catalog'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $all_results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize raw modelSearch result objects to the Image_Model_Registry shape.
|
||||||
|
*
|
||||||
|
* Filters to models with "io:text-to-image" in capabilities[].
|
||||||
|
* Uses the `air` field as the model id (e.g. "runware:100@1").
|
||||||
|
* Uses the `comment` field as the human-readable description.
|
||||||
|
* cost_per_image is always null here — merged by the catalog class.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $raw_models Raw modelSearch results.
|
||||||
|
* @return array<int, array<string, mixed>> Normalized models.
|
||||||
|
*/
|
||||||
|
public function normalize_models( array $raw_models ): array {
|
||||||
|
$normalized = array();
|
||||||
|
|
||||||
|
foreach ( $raw_models as $raw ) {
|
||||||
|
if ( ! is_array( $raw ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$capabilities = is_array( $raw['capabilities'] ?? null ) ? $raw['capabilities'] : array();
|
||||||
|
if ( ! in_array( 'io:text-to-image', $capabilities, true ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$air = (string) ( $raw['air'] ?? '' );
|
||||||
|
if ( '' === $air ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[] = array(
|
||||||
|
'id' => $air,
|
||||||
|
'name' => (string) ( $raw['name'] ?? $air ),
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => null,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => (string) ( $raw['comment'] ?? '' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single page of modelSearch results.
|
||||||
|
*
|
||||||
|
* Sends [authentication, modelSearch] task pair to /v1.
|
||||||
|
* A fresh auth task is required per request per Runware's WebSocket-based
|
||||||
|
* API contract (each HTTP POST is a stateless task array).
|
||||||
|
*
|
||||||
|
* @param string $api_key Decrypted Runware API key.
|
||||||
|
* @param int $offset Pagination offset (0-based).
|
||||||
|
* @param int $limit Page size.
|
||||||
|
* @return array{results: array<int, array<string, mixed>>, totalResults: int}
|
||||||
|
* @throws \RuntimeException On HTTP error or malformed response.
|
||||||
|
*/
|
||||||
|
private function fetch_page( string $api_key, int $offset, int $limit ): array {
|
||||||
|
$payload = array(
|
||||||
|
array(
|
||||||
|
'taskType' => 'authentication',
|
||||||
|
'apiKey' => $api_key,
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'taskType' => 'modelSearch',
|
||||||
|
'taskUUID' => wp_generate_uuid4(),
|
||||||
|
'offset' => $offset,
|
||||||
|
'limit' => $limit,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = wp_remote_post(
|
||||||
|
self::RUNWARE_API_URL,
|
||||||
|
array(
|
||||||
|
'timeout' => self::HTTP_TIMEOUT_SEC,
|
||||||
|
'headers' => array( 'Content-Type' => 'application/json' ),
|
||||||
|
'body' => wp_json_encode( $payload ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf( 'Runware modelSearch unreachable: %s', $response->get_error_message() )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||||
|
if ( 200 !== $status ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf( 'Runware modelSearch returned HTTP %d', $status )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = (string) wp_remote_retrieve_body( $response );
|
||||||
|
$decoded = json_decode( $raw, true );
|
||||||
|
|
||||||
|
if ( ! is_array( $decoded ) ) {
|
||||||
|
throw new \RuntimeException( 'Runware modelSearch response is not valid JSON.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
|
||||||
|
$first_msg = '';
|
||||||
|
if ( ! empty( $decoded['errors'][0]['message'] ) ) {
|
||||||
|
$first_msg = ': ' . (string) $decoded['errors'][0]['message'];
|
||||||
|
}
|
||||||
|
throw new \RuntimeException( 'Runware modelSearch API error' . $first_msg );
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $decoded['data'] ?? array();
|
||||||
|
if ( ! is_array( $data ) || empty( $data ) ) {
|
||||||
|
return array(
|
||||||
|
'results' => array(),
|
||||||
|
'totalResults' => 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = $data[0];
|
||||||
|
$results = is_array( $item['results'] ?? null ) ? $item['results'] : array();
|
||||||
|
$total = isset( $item['totalResults'] ) ? (int) $item['totalResults'] : 0;
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'results' => $results,
|
||||||
|
'totalResults' => $total,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,18 +4,30 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
*
|
*
|
||||||
* Runware model catalog sync — fetches live model list via Runware's /v1 endpoint,
|
* Runware model catalog sync — orchestrates periodic fetching, caching, failure
|
||||||
* filters to text-to-image tasks, caches normalized results, and falls back to
|
* cooldown, and fallback for the Runware model list.
|
||||||
* hardcoded list on API failure.
|
|
||||||
*
|
*
|
||||||
* What: Syncs Runware's model catalog periodically (daily cron + on-demand AJAX),
|
* What: Syncs Runware's model catalog (daily cron + on-demand AJAX), caches
|
||||||
* caches the results in WP options, and merges pricing data from the pricing
|
* normalized results in WP options (v2 schema key — see below), merges
|
||||||
* class when Runware's API doesn't expose it.
|
* pricing, and enforces a per-failure cooldown to stop admin-page-load
|
||||||
|
* cascades when the API is broken or the key is misconfigured.
|
||||||
* Who triggers it: Daily cron (prautoblogger_sync_runware_models), AJAX "Sync now"
|
* Who triggers it: Daily cron (prautoblogger_sync_runware_models), AJAX "Sync now"
|
||||||
* button in PRAdmin, and lazy-load via PRAutoBlogger_Image_Model_Registry.
|
* button in PRAdmin, and lazy-load via PRAutoBlogger_Image_Model_Registry.
|
||||||
* Dependencies: PRAutoBlogger_Logger, PRAutoBlogger_Runware_Image_Pricing,
|
* Dependencies: PRAutoBlogger_Runware_Catalog_Fetcher, PRAutoBlogger_Logger,
|
||||||
* PRAutoBlogger_Image_Model_Registry.
|
* PRAutoBlogger_Runware_Image_Pricing, PRAutoBlogger_Image_Model_Registry.
|
||||||
*
|
*
|
||||||
|
* Cache schema note: option key is prautoblogger_runware_model_cache_v2 (bumped
|
||||||
|
* from v1) to cleanly invalidate cached rows that used the old taskType:models
|
||||||
|
* response shape (taskType/requiresImage fields). The v1 option is left in place
|
||||||
|
* and will be purged by the next WP options cleanup pass.
|
||||||
|
*
|
||||||
|
* Failure cooldown: prautoblogger_runware_catalog_last_failure_at records the
|
||||||
|
* last failure timestamp. is_in_failure_cooldown() checks it against
|
||||||
|
* prautoblogger_runware_catalog_failure_cooldown_seconds (default 3600s). This
|
||||||
|
* caps errors from admin page loads to ~24/day (24h / 1h cooldown) rather than
|
||||||
|
* 40+/day from every Settings/picker page load triggering a live sync.
|
||||||
|
*
|
||||||
|
* @see class-runware-catalog-fetcher.php — Pagination + normalize (extracted for 300-line rule).
|
||||||
* @see class-runware-image-pricing.php — Authoritative pricing source & fallback models.
|
* @see class-runware-image-pricing.php — Authoritative pricing source & fallback models.
|
||||||
* @see admin/class-image-model-registry.php — Caller; merges Runware + OpenRouter lists.
|
* @see admin/class-image-model-registry.php — Caller; merges Runware + OpenRouter lists.
|
||||||
* @see class-prautoblogger.php — Registers daily cron + AJAX hook.
|
* @see class-prautoblogger.php — Registers daily cron + AJAX hook.
|
||||||
@@ -23,20 +35,27 @@ declare(strict_types=1);
|
|||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Runware_Model_Catalog {
|
class PRAutoBlogger_Runware_Model_Catalog {
|
||||||
|
|
||||||
private const RUNWARE_API_URL = 'https://api.runware.ai/v1';
|
|
||||||
private const CACHE_TTL_SECONDS = 86400;
|
private const CACHE_TTL_SECONDS = 86400;
|
||||||
private const HTTP_TIMEOUT_SECONDS = 30;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the Runware model catalog, normalize to the Image_Model_Registry
|
* Option key for the v2 normalized model cache (air-identifier schema).
|
||||||
* shape, and write to WP options cache. Returns true on success, false on
|
* Bumped from v1 (prautoblogger_runware_model_cache) to force re-sync.
|
||||||
* API failure or parse error.
|
*/
|
||||||
|
private const CACHE_OPTION = 'prautoblogger_runware_model_cache_v2';
|
||||||
|
private const CACHE_TS_OPTION = 'prautoblogger_runware_model_cache_updated_at';
|
||||||
|
private const FAILURE_TS_OPTION = 'prautoblogger_runware_catalog_last_failure_at';
|
||||||
|
private const COOLDOWN_SETTING = 'prautoblogger_runware_catalog_failure_cooldown_seconds';
|
||||||
|
private const COOLDOWN_DEFAULT = 3600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch, normalize, merge pricing, and cache the Runware model catalog.
|
||||||
|
* On success: writes cache + clears failure timestamp. Returns true.
|
||||||
|
* On failure: writes failure timestamp for cooldown. Returns false.
|
||||||
*
|
*
|
||||||
* Side effects: makes HTTP request to Runware API, writes WP options.
|
|
||||||
* Error handling: logs via PRAutoBlogger_Logger, never throws, always
|
* Error handling: logs via PRAutoBlogger_Logger, never throws, always
|
||||||
* returns bool to allow fallback to cached list.
|
* returns bool to allow fallback to cached/hardcoded list.
|
||||||
*
|
*
|
||||||
* @return bool True on successful fetch and cache update; false on API or parse error.
|
* @return bool True on successful fetch and cache update; false otherwise.
|
||||||
*/
|
*/
|
||||||
public function sync(): bool {
|
public function sync(): bool {
|
||||||
try {
|
try {
|
||||||
@@ -49,16 +68,19 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$raw_models = $this->fetch_models_from_api( $api_key );
|
$fetcher = new PRAutoBlogger_Runware_Catalog_Fetcher();
|
||||||
|
$raw_models = $fetcher->fetch_all_text_to_image( $api_key );
|
||||||
|
|
||||||
if ( empty( $raw_models ) ) {
|
if ( empty( $raw_models ) ) {
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
'Runware model catalog fetch returned empty list. Using cached/fallback models.',
|
'Runware model catalog fetch returned empty list. Using cached/fallback models.',
|
||||||
'runware-catalog'
|
'runware-catalog'
|
||||||
);
|
);
|
||||||
|
$this->record_failure();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$normalized = $this->normalize_models( $raw_models );
|
$normalized = $fetcher->normalize_models( $raw_models );
|
||||||
$with_pricing = $this->merge_pricing( $normalized );
|
$with_pricing = $this->merge_pricing( $normalized );
|
||||||
|
|
||||||
if ( empty( $with_pricing ) ) {
|
if ( empty( $with_pricing ) ) {
|
||||||
@@ -66,11 +88,13 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
|||||||
'Runware model catalog normalization resulted in empty list. Using cached/fallback models.',
|
'Runware model catalog normalization resulted in empty list. Using cached/fallback models.',
|
||||||
'runware-catalog'
|
'runware-catalog'
|
||||||
);
|
);
|
||||||
|
$this->record_failure();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
update_option( 'prautoblogger_runware_model_cache', $with_pricing, false );
|
update_option( self::CACHE_OPTION, $with_pricing, false );
|
||||||
update_option( 'prautoblogger_runware_model_cache_updated_at', time(), false );
|
update_option( self::CACHE_TS_OPTION, time(), false );
|
||||||
|
delete_option( self::FAILURE_TS_OPTION );
|
||||||
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
sprintf( 'Runware model catalog synced: %d models cached.', count( $with_pricing ) ),
|
sprintf( 'Runware model catalog synced: %d models cached.', count( $with_pricing ) ),
|
||||||
@@ -82,38 +106,37 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
|||||||
sprintf( 'Runware model catalog sync failed: %s (%s)', $e->getMessage(), get_class( $e ) ),
|
sprintf( 'Runware model catalog sync failed: %s (%s)', $e->getMessage(), get_class( $e ) ),
|
||||||
'runware-catalog'
|
'runware-catalog'
|
||||||
);
|
);
|
||||||
|
$this->record_failure();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the normalized model list, with smart caching logic:
|
* Return the normalized model list, using smart cache + fallback logic:
|
||||||
* - If cache is fresh (< 24h), return it.
|
* fresh cache → cached; stale + not in cooldown → sync; stale cache fallback;
|
||||||
* - If cache is stale or absent, trigger a sync.
|
* no cache → hardcoded fallback (never empty).
|
||||||
* - If sync fails, return the last-known-good cache.
|
|
||||||
* - If no cache exists, return the hardcoded fallback (never empty).
|
|
||||||
*
|
*
|
||||||
* @return array<int, array<string, mixed>> Normalized model list.
|
* @return array<int, array<string, mixed>> Normalized model list.
|
||||||
*/
|
*/
|
||||||
public function get_models(): array {
|
public function get_models(): array {
|
||||||
if ( ! $this->is_stale() ) {
|
if ( ! $this->is_stale() ) {
|
||||||
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
|
$cached = get_option( self::CACHE_OPTION, array() );
|
||||||
if ( is_array( $cached ) && ! empty( $cached ) ) {
|
if ( is_array( $cached ) && ! empty( $cached ) ) {
|
||||||
return $cached;
|
return $cached;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( $this->sync() ) {
|
if ( ! $this->is_in_failure_cooldown() && $this->sync() ) {
|
||||||
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
|
$cached = get_option( self::CACHE_OPTION, array() );
|
||||||
if ( is_array( $cached ) && ! empty( $cached ) ) {
|
if ( is_array( $cached ) && ! empty( $cached ) ) {
|
||||||
return $cached;
|
return $cached;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
|
$cached = get_option( self::CACHE_OPTION, array() );
|
||||||
if ( is_array( $cached ) && ! empty( $cached ) ) {
|
if ( is_array( $cached ) && ! empty( $cached ) ) {
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
'Using stale Runware model cache (live sync failed).',
|
'Using stale Runware model cache (live sync failed or suppressed by cooldown).',
|
||||||
'runware-catalog'
|
'runware-catalog'
|
||||||
);
|
);
|
||||||
return $cached;
|
return $cached;
|
||||||
@@ -131,12 +154,12 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the Unix timestamp of the last successful sync, or null if never synced.
|
* Unix timestamp of the last successful sync, or null if never synced.
|
||||||
*
|
*
|
||||||
* @return int|null Unix timestamp, or null if never synced.
|
* @return int|null
|
||||||
*/
|
*/
|
||||||
public function get_last_synced_at(): ?int {
|
public function get_last_synced_at(): ?int {
|
||||||
$ts = get_option( 'prautoblogger_runware_model_cache_updated_at', null );
|
$ts = get_option( self::CACHE_TS_OPTION, null );
|
||||||
if ( null === $ts ) {
|
if ( null === $ts ) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -145,9 +168,23 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the cache is stale (older than 24h) or absent.
|
* Unix timestamp of the last sync failure, or null if no failure on record.
|
||||||
*
|
*
|
||||||
* @return bool True if cache should be refreshed; false if still fresh.
|
* @return int|null
|
||||||
|
*/
|
||||||
|
public function get_last_failure_at(): ?int {
|
||||||
|
$ts = get_option( self::FAILURE_TS_OPTION, null );
|
||||||
|
if ( null === $ts ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$ts = (int) $ts;
|
||||||
|
return $ts > 0 ? $ts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the cache is stale (older than 24h) or absent.
|
||||||
|
*
|
||||||
|
* @return bool True if a refresh is warranted; false if still fresh.
|
||||||
*/
|
*/
|
||||||
public function is_stale(): bool {
|
public function is_stale(): bool {
|
||||||
$updated_at = $this->get_last_synced_at();
|
$updated_at = $this->get_last_synced_at();
|
||||||
@@ -158,125 +195,53 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the raw model list from Runware's /v1 endpoint via a models task.
|
* Whether a sync attempt should be suppressed due to a recent failure.
|
||||||
*
|
*
|
||||||
* @param string $api_key Decrypted Runware API key.
|
* The cooldown window is read from prautoblogger_runware_catalog_failure_cooldown_seconds
|
||||||
* @return array Raw model list (may be empty).
|
* (default 3600s). This prevents admin-page-load cascades (40+ errors/day)
|
||||||
* @throws \RuntimeException On HTTP error or malformed response.
|
* when the API endpoint or key is broken. A successful sync clears it.
|
||||||
|
*
|
||||||
|
* @return bool True if a sync should be suppressed; false if retry is permitted.
|
||||||
*/
|
*/
|
||||||
private function fetch_models_from_api( string $api_key ): array {
|
public function is_in_failure_cooldown(): bool {
|
||||||
$body = wp_json_encode(
|
$last_failure = $this->get_last_failure_at();
|
||||||
array(
|
if ( null === $last_failure ) {
|
||||||
array(
|
return false;
|
||||||
'taskType' => 'models',
|
|
||||||
'apiKey' => $api_key,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = wp_remote_post(
|
|
||||||
self::RUNWARE_API_URL,
|
|
||||||
array(
|
|
||||||
'timeout' => self::HTTP_TIMEOUT_SECONDS,
|
|
||||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
|
||||||
'body' => $body,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( is_wp_error( $response ) ) {
|
|
||||||
throw new \RuntimeException(
|
|
||||||
sprintf( 'Runware models endpoint unreachable: %s', $response->get_error_message() )
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
$cooldown = (int) get_option( self::COOLDOWN_SETTING, self::COOLDOWN_DEFAULT );
|
||||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
if ( $cooldown <= 0 ) {
|
||||||
if ( 200 !== $status ) {
|
$cooldown = self::COOLDOWN_DEFAULT;
|
||||||
$raw = wp_remote_retrieve_body( $response );
|
|
||||||
throw new \RuntimeException(
|
|
||||||
sprintf( 'Runware models endpoint returned HTTP %d', $status )
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return ( time() - $last_failure ) < $cooldown;
|
||||||
$raw = (string) wp_remote_retrieve_body( $response );
|
|
||||||
$decoded = json_decode( $raw, true );
|
|
||||||
|
|
||||||
if ( ! is_array( $decoded ) ) {
|
|
||||||
throw new \RuntimeException( 'Runware models response is not valid JSON.' );
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
|
|
||||||
throw new \RuntimeException( 'Runware API returned an error.' );
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $decoded['data'] ?? array();
|
|
||||||
return is_array( $data ) ? $data : array();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize raw Runware model objects to the PRAutoBlogger_Image_Model_Registry
|
* Merge pricing from PRAutoBlogger_Runware_Image_Pricing into normalized models.
|
||||||
* shape: id, name, provider, cost_per_image, capabilities, description.
|
* Models without a pricing entry get cost_per_image=null.
|
||||||
*
|
|
||||||
* Filters to taskType='imageInference' (text-to-image only), excluding
|
|
||||||
* inpainting (imageInference + requiresImage=true), img2img, video, upscalers.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $raw_models Raw API response.
|
|
||||||
* @return array<int, array<string, mixed>> Normalized models.
|
|
||||||
*/
|
|
||||||
private function normalize_models( array $raw_models ): array {
|
|
||||||
$normalized = array();
|
|
||||||
|
|
||||||
foreach ( $raw_models as $raw ) {
|
|
||||||
if ( ! is_array( $raw ) ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$task_type = $raw['taskType'] ?? '';
|
|
||||||
if ( 'imageInference' !== $task_type ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( isset( $raw['requiresImage'] ) && true === $raw['requiresImage'] ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$model_id = (string) ( $raw['id'] ?? '' );
|
|
||||||
if ( '' === $model_id ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized[] = array(
|
|
||||||
'id' => $model_id,
|
|
||||||
'name' => (string) ( $raw['name'] ?? $model_id ),
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => null,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => (string) ( $raw['description'] ?? '' ),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge pricing data from PRAutoBlogger_Runware_Image_Pricing into the
|
|
||||||
* normalized catalog. Models without pricing data get cost_per_image=null.
|
|
||||||
*
|
*
|
||||||
* @param array<int, array<string, mixed>> $normalized Normalized models.
|
* @param array<int, array<string, mixed>> $normalized Normalized models.
|
||||||
* @return array<int, array<string, mixed>> Models with pricing merged.
|
* @return array<int, array<string, mixed>> Models with pricing merged.
|
||||||
*/
|
*/
|
||||||
private function merge_pricing( array $normalized ): array {
|
private function merge_pricing( array $normalized ): array {
|
||||||
$pricing_table = PRAutoBlogger_Runware_Image_Pricing::get_model_costs();
|
$pricing_table = PRAutoBlogger_Runware_Image_Pricing::get_model_costs();
|
||||||
|
|
||||||
foreach ( $normalized as &$model ) {
|
foreach ( $normalized as &$model ) {
|
||||||
$model_id = $model['id'] ?? '';
|
$model_id = $model['id'] ?? '';
|
||||||
if ( isset( $pricing_table[ $model_id ] ) ) {
|
if ( isset( $pricing_table[ $model_id ] ) ) {
|
||||||
$model['cost_per_image'] = (float) $pricing_table[ $model_id ];
|
$model['cost_per_image'] = (float) $pricing_table[ $model_id ];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $normalized;
|
return $normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the current time as the last sync failure timestamp.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function record_failure(): void {
|
||||||
|
update_option( self::FAILURE_TS_OPTION, time(), false );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the decrypted Runware API key from settings.
|
* Get the decrypted Runware API key from settings.
|
||||||
*
|
*
|
||||||
@@ -287,12 +252,10 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
|||||||
if ( '' === $stored ) {
|
if ( '' === $stored ) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
|
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
|
||||||
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
|
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
|
||||||
return '' === $decrypted ? '' : $decrypted;
|
return '' === $decrypted ? '' : $decrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $stored;
|
return $stored;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
131
tests/fixtures/runware-model-search-page1.json
vendored
Normal file
131
tests/fixtures/runware-model-search-page1.json
vendored
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"taskType": "modelSearch",
|
||||||
|
"taskUUID": "aabbccdd-0000-0000-0000-000000000001",
|
||||||
|
"totalResults": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"air": "runware:100@1",
|
||||||
|
"name": "FLUX.1 schnell",
|
||||||
|
"nameContent": "FLUX.1 schnell",
|
||||||
|
"comment": "Fast 4-step text-to-image model",
|
||||||
|
"category": "checkpoint",
|
||||||
|
"private": false,
|
||||||
|
"capabilities": ["io:text-to-image", "form:checkpoint"],
|
||||||
|
"tags": [],
|
||||||
|
"heroImage": "",
|
||||||
|
"version": "",
|
||||||
|
"architecture": "",
|
||||||
|
"nsfwLevel": null,
|
||||||
|
"downloadCount": 0,
|
||||||
|
"inferenceCount24h": 0,
|
||||||
|
"rating": 0,
|
||||||
|
"ratingCount": 0,
|
||||||
|
"thumbsUpCount": 0,
|
||||||
|
"thumbsDownCount": 0,
|
||||||
|
"featured": 1,
|
||||||
|
"addedUnixTimestamp": 0,
|
||||||
|
"updatedDateUnixTimestamp": 0,
|
||||||
|
"source": "curated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"air": "runware:101@1",
|
||||||
|
"name": "FLUX.1 dev",
|
||||||
|
"nameContent": "FLUX.1 dev",
|
||||||
|
"comment": "High fidelity 28-step text-to-image model",
|
||||||
|
"category": "checkpoint",
|
||||||
|
"private": false,
|
||||||
|
"capabilities": ["io:text-to-image", "form:checkpoint"],
|
||||||
|
"tags": [],
|
||||||
|
"heroImage": "",
|
||||||
|
"version": "",
|
||||||
|
"architecture": "",
|
||||||
|
"nsfwLevel": null,
|
||||||
|
"downloadCount": 0,
|
||||||
|
"inferenceCount24h": 0,
|
||||||
|
"rating": 0,
|
||||||
|
"ratingCount": 0,
|
||||||
|
"thumbsUpCount": 0,
|
||||||
|
"thumbsDownCount": 0,
|
||||||
|
"featured": 0,
|
||||||
|
"addedUnixTimestamp": 0,
|
||||||
|
"updatedDateUnixTimestamp": 0,
|
||||||
|
"source": "curated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"air": "some-text-model:llm@1",
|
||||||
|
"name": "Some Text LLM",
|
||||||
|
"nameContent": "Some Text LLM",
|
||||||
|
"comment": "A text-to-text model that should be excluded",
|
||||||
|
"category": "text",
|
||||||
|
"private": false,
|
||||||
|
"capabilities": ["io:text-to-text", "form:checkpoint"],
|
||||||
|
"tags": [],
|
||||||
|
"heroImage": "",
|
||||||
|
"version": "",
|
||||||
|
"architecture": "",
|
||||||
|
"nsfwLevel": null,
|
||||||
|
"downloadCount": 0,
|
||||||
|
"inferenceCount24h": 0,
|
||||||
|
"rating": 0,
|
||||||
|
"ratingCount": 0,
|
||||||
|
"thumbsUpCount": 0,
|
||||||
|
"thumbsDownCount": 0,
|
||||||
|
"featured": 0,
|
||||||
|
"addedUnixTimestamp": 0,
|
||||||
|
"updatedDateUnixTimestamp": 0,
|
||||||
|
"source": "curated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"air": "runware:400@2",
|
||||||
|
"name": "FLUX.2 klein 9B",
|
||||||
|
"nameContent": "FLUX.2 klein 9B",
|
||||||
|
"comment": "Compact 9B text-to-image model",
|
||||||
|
"category": "checkpoint",
|
||||||
|
"private": false,
|
||||||
|
"capabilities": ["io:text-to-image", "form:checkpoint"],
|
||||||
|
"tags": [],
|
||||||
|
"heroImage": "",
|
||||||
|
"version": "",
|
||||||
|
"architecture": "",
|
||||||
|
"nsfwLevel": null,
|
||||||
|
"downloadCount": 0,
|
||||||
|
"inferenceCount24h": 0,
|
||||||
|
"rating": 0,
|
||||||
|
"ratingCount": 0,
|
||||||
|
"thumbsUpCount": 0,
|
||||||
|
"thumbsDownCount": 0,
|
||||||
|
"featured": 0,
|
||||||
|
"addedUnixTimestamp": 0,
|
||||||
|
"updatedDateUnixTimestamp": 0,
|
||||||
|
"source": "curated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"air": "upscale-model:upscaler@1",
|
||||||
|
"name": "Upscale Model",
|
||||||
|
"nameContent": "Upscale Model",
|
||||||
|
"comment": "Image upscaler — should be excluded from t2i list",
|
||||||
|
"category": "checkpoint",
|
||||||
|
"private": false,
|
||||||
|
"capabilities": ["io:image-to-image", "op:upscale", "form:checkpoint"],
|
||||||
|
"tags": [],
|
||||||
|
"heroImage": "",
|
||||||
|
"version": "",
|
||||||
|
"architecture": "",
|
||||||
|
"nsfwLevel": null,
|
||||||
|
"downloadCount": 0,
|
||||||
|
"inferenceCount24h": 0,
|
||||||
|
"rating": 0,
|
||||||
|
"ratingCount": 0,
|
||||||
|
"thumbsUpCount": 0,
|
||||||
|
"thumbsDownCount": 0,
|
||||||
|
"featured": 0,
|
||||||
|
"addedUnixTimestamp": 0,
|
||||||
|
"updatedDateUnixTimestamp": 0,
|
||||||
|
"source": "curated"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for PRAutoBlogger_Runware_Model_Catalog.
|
* Tests for PRAutoBlogger_Runware_Model_Catalog.
|
||||||
*
|
*
|
||||||
* Validates live sync, fallback logic, cache staleness, and pricing merge.
|
* Validates: normalize_models against real modelSearch fixture; cooldown behavior
|
||||||
|
* (failure → no retry within window → retry after); fallback served when cache empty;
|
||||||
|
* success path clears failure timestamp; cache version key v2 is used throughout.
|
||||||
*
|
*
|
||||||
* @package PRAutoBlogger\Tests\Providers
|
* @package PRAutoBlogger\Tests\Providers
|
||||||
*/
|
*/
|
||||||
@@ -11,47 +13,267 @@ namespace PRAutoBlogger\Tests\Providers;
|
|||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use Brain\Monkey\Actions;
|
|
||||||
|
|
||||||
class RunwareModelCatalogTest extends BaseTestCase {
|
class RunwareModelCatalogTest extends BaseTestCase {
|
||||||
|
|
||||||
|
/** Shared in-memory option store reset per test. */
|
||||||
|
private array $options = array();
|
||||||
|
|
||||||
protected function setUp(): void {
|
protected function setUp(): void {
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
// Mock WP options for cache simulation.
|
$this->options = array();
|
||||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
$this->stub_options();
|
||||||
static $options = array();
|
|
||||||
return $options[ $key ] ?? $default;
|
|
||||||
} );
|
|
||||||
Functions\when( 'update_option' )->alias( function ( $key, $value ) {
|
|
||||||
static $options = array();
|
|
||||||
$options[ $key ] = $value;
|
|
||||||
return true;
|
|
||||||
} );
|
|
||||||
Functions\when( 'time' )->justReturn( 1704067200 ); // 2024-01-01 00:00:00
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sync with empty or missing API key returns false. */
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
public function test_sync_no_api_key(): void {
|
|
||||||
|
private function stub_options(): void {
|
||||||
|
Functions\when( 'get_option' )->alias(
|
||||||
|
function ( $key, $default = false ) {
|
||||||
|
return $this->options[ $key ] ?? $default;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Functions\when( 'update_option' )->alias(
|
||||||
|
function ( $key, $value ) {
|
||||||
|
$this->options[ $key ] = $value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Functions\when( 'delete_option' )->alias(
|
||||||
|
function ( $key ) {
|
||||||
|
unset( $this->options[ $key ] );
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Functions\when( 'time' )->justReturn( 1704067200 ); // 2024-01-01 00:00:00 UTC
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the fixture as a decoded page array matching fetch_page() output.
|
||||||
|
*
|
||||||
|
* @return array{results: array, totalResults: int}
|
||||||
|
*/
|
||||||
|
private function get_fixture_page(): array {
|
||||||
|
$fixture_path = __DIR__ . '/../../fixtures/runware-model-search-page1.json';
|
||||||
|
$json = file_get_contents( $fixture_path );
|
||||||
|
$decoded = json_decode( $json, true );
|
||||||
|
$item = $decoded['data'][0];
|
||||||
|
return array(
|
||||||
|
'results' => $item['results'],
|
||||||
|
'totalResults' => $item['totalResults'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fake HTTP response array for wp_remote_post stubs.
|
||||||
|
*
|
||||||
|
* @param int $status HTTP status code.
|
||||||
|
* @param string $body Response body JSON.
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private function make_http_response( int $status, string $body ): array {
|
||||||
|
return array(
|
||||||
|
'response' => array( 'code' => $status ),
|
||||||
|
'body' => $body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stub_http_helpers(): void {
|
||||||
|
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
|
||||||
|
function ( $r ) { return (int) ( $r['response']['code'] ?? 0 ); }
|
||||||
|
);
|
||||||
|
Functions\when( 'wp_remote_retrieve_body' )->alias(
|
||||||
|
function ( $r ) { return (string) ( $r['body'] ?? '' ); }
|
||||||
|
);
|
||||||
|
Functions\when( 'is_wp_error' )->justReturn( false );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── normalize_models tests (via Fetcher) ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* normalize_models filters to io:text-to-image and maps air → id,
|
||||||
|
* comment → description. Non-t2i models are excluded.
|
||||||
|
*
|
||||||
|
* Uses the real modelSearch fixture (3 t2i + 1 text LLM + 1 upscaler).
|
||||||
|
* Expects only the 3 t2i models to pass through.
|
||||||
|
*/
|
||||||
|
public function test_normalize_models_filters_to_text_to_image(): void {
|
||||||
|
$fixture = $this->get_fixture_page();
|
||||||
|
$fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher();
|
||||||
|
$result = $fetcher->normalize_models( $fixture['results'] );
|
||||||
|
|
||||||
|
// Fixture has 3 t2i models (runware:100@1, runware:101@1, runware:400@2)
|
||||||
|
// and 2 non-t2i (text LLM + upscaler).
|
||||||
|
$this->assertCount( 3, $result, 'Should include exactly 3 io:text-to-image models' );
|
||||||
|
|
||||||
|
$ids = array_column( $result, 'id' );
|
||||||
|
$this->assertContains( 'runware:100@1', $ids );
|
||||||
|
$this->assertContains( 'runware:101@1', $ids );
|
||||||
|
$this->assertContains( 'runware:400@2', $ids );
|
||||||
|
$this->assertNotContains( 'some-text-model:llm@1', $ids, 'Text LLM must be excluded' );
|
||||||
|
$this->assertNotContains( 'upscale-model:upscaler@1', $ids, 'Upscaler must be excluded' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** normalize_models uses air as id, name as name, comment as description. */
|
||||||
|
public function test_normalize_models_maps_fields_correctly(): void {
|
||||||
|
$fixture = $this->get_fixture_page();
|
||||||
|
$fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher();
|
||||||
|
$result = $fetcher->normalize_models( $fixture['results'] );
|
||||||
|
|
||||||
|
$schnell = null;
|
||||||
|
foreach ( $result as $m ) {
|
||||||
|
if ( 'runware:100@1' === $m['id'] ) {
|
||||||
|
$schnell = $m;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertNotNull( $schnell );
|
||||||
|
$this->assertSame( 'runware:100@1', $schnell['id'] );
|
||||||
|
$this->assertSame( 'FLUX.1 schnell', $schnell['name'] );
|
||||||
|
$this->assertSame( 'runware', $schnell['provider'] );
|
||||||
|
$this->assertNull( $schnell['cost_per_image'], 'Pricing is null before merge_pricing runs' );
|
||||||
|
$this->assertSame( array( 'image_generation' ), $schnell['capabilities'] );
|
||||||
|
$this->assertSame( 'Fast 4-step text-to-image model', $schnell['description'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** normalize_models skips entries with empty air field. */
|
||||||
|
public function test_normalize_models_skips_empty_air(): void {
|
||||||
|
$fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher();
|
||||||
|
$raw = array(
|
||||||
|
array(
|
||||||
|
'air' => '',
|
||||||
|
'name' => 'No Air Model',
|
||||||
|
'capabilities' => array( 'io:text-to-image' ),
|
||||||
|
'comment' => '',
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'air' => 'runware:100@1',
|
||||||
|
'name' => 'Valid',
|
||||||
|
'capabilities' => array( 'io:text-to-image' ),
|
||||||
|
'comment' => 'OK',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $fetcher->normalize_models( $raw );
|
||||||
|
$this->assertCount( 1, $result );
|
||||||
|
$this->assertSame( 'runware:100@1', $result[0]['id'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** normalize_models returns empty array for empty input. */
|
||||||
|
public function test_normalize_models_empty_input(): void {
|
||||||
|
$fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher();
|
||||||
|
$this->assertSame( array(), $fetcher->normalize_models( array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Failure cooldown tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** No failure on record → is_in_failure_cooldown returns false. */
|
||||||
|
public function test_no_failure_cooldown_when_no_failure_recorded(): void {
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
$result = $catalog->sync();
|
$this->assertFalse( $catalog->is_in_failure_cooldown() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Failure recorded at t=now → cooldown active within 1h window. */
|
||||||
|
public function test_cooldown_active_within_window(): void {
|
||||||
|
$now = 1704067200;
|
||||||
|
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $now;
|
||||||
|
// time() is stubbed to $now, so age = 0 < 3600 = cooldown.
|
||||||
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$this->assertTrue( $catalog->is_in_failure_cooldown() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Failure recorded > 1h ago → cooldown expired, retry permitted. */
|
||||||
|
public function test_cooldown_expired_after_window(): void {
|
||||||
|
$now = 1704067200;
|
||||||
|
$past = $now - 3601; // More than 1h ago.
|
||||||
|
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $past;
|
||||||
|
|
||||||
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$this->assertFalse( $catalog->is_in_failure_cooldown() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Custom cooldown from settings is respected. */
|
||||||
|
public function test_cooldown_uses_setting_value(): void {
|
||||||
|
$now = 1704067200;
|
||||||
|
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $now - 100;
|
||||||
|
// Default 3600s would make this expired (100 < 3600), but set custom = 60s.
|
||||||
|
// 100s > 60s so it should be expired.
|
||||||
|
$this->options['prautoblogger_runware_catalog_failure_cooldown_seconds'] = 60;
|
||||||
|
|
||||||
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$this->assertFalse( $catalog->is_in_failure_cooldown() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sync failure sets last_failure_at and does not clear success timestamp. */
|
||||||
|
public function test_sync_failure_records_failure_timestamp(): void {
|
||||||
|
// Mock HTTP 401 to force sync failure.
|
||||||
|
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
|
||||||
|
$this->stub_http_helpers();
|
||||||
|
Functions\when( 'wp_remote_post' )->justReturn(
|
||||||
|
$this->make_http_response( 401, '{"errors":[{"message":"Unauthorized"}]}' )
|
||||||
|
);
|
||||||
|
|
||||||
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$result = $catalog->sync();
|
||||||
|
|
||||||
$this->assertFalse( $result );
|
$this->assertFalse( $result );
|
||||||
|
$this->assertNotNull( $catalog->get_last_failure_at(), 'Failure timestamp should be recorded' );
|
||||||
}
|
}
|
||||||
|
|
||||||
/** get_models returns fallback if no cache and sync fails. */
|
/** After failure, get_models suppresses sync within cooldown window. */
|
||||||
public function test_get_models_fallback_on_sync_failure(): void {
|
public function test_get_models_suppresses_sync_during_cooldown(): void {
|
||||||
|
$now = 1704067200;
|
||||||
|
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
|
||||||
|
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $now; // just failed.
|
||||||
|
$this->options['prautoblogger_runware_model_cache_updated_at'] = $now - 90000; // stale cache.
|
||||||
|
|
||||||
|
$this->stub_http_helpers();
|
||||||
|
// wp_remote_post should NOT be called during cooldown.
|
||||||
|
Functions\expect( 'wp_remote_post' )->never();
|
||||||
|
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
$models = $catalog->get_models();
|
$models = $catalog->get_models();
|
||||||
|
|
||||||
|
// Should fall back to hardcoded fallback list.
|
||||||
|
$this->assertNotEmpty( $models );
|
||||||
|
$ids = array_column( $models, 'id' );
|
||||||
|
$this->assertContains( 'runware:100@1', $ids, 'Fallback schnell model should be present' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Successful sync clears failure timestamp. */
|
||||||
|
public function test_successful_sync_clears_failure_timestamp(): void {
|
||||||
|
$now = 1704067200;
|
||||||
|
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
|
||||||
|
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $now - 7200; // old failure.
|
||||||
|
|
||||||
|
$fixture_body = file_get_contents( __DIR__ . '/../../fixtures/runware-model-search-page1.json' );
|
||||||
|
$this->stub_http_helpers();
|
||||||
|
Functions\when( 'wp_remote_post' )->justReturn(
|
||||||
|
$this->make_http_response( 200, $fixture_body )
|
||||||
|
);
|
||||||
|
|
||||||
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$result = $catalog->sync();
|
||||||
|
|
||||||
|
$this->assertTrue( $result, 'Sync should succeed with valid fixture response' );
|
||||||
|
$this->assertNull( $catalog->get_last_failure_at(), 'Success should clear failure timestamp' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fallback list tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** get_models returns fallback when no cache and sync fails. */
|
||||||
|
public function test_get_models_returns_fallback_when_no_cache(): void {
|
||||||
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$models = $catalog->get_models();
|
||||||
|
|
||||||
// Should have the fallback list (15 Runware models).
|
|
||||||
$this->assertIsArray( $models );
|
$this->assertIsArray( $models );
|
||||||
$this->assertGreaterThan( 0, count( $models ) );
|
$this->assertNotEmpty( $models );
|
||||||
|
|
||||||
// Check at least one expected fallback model exists.
|
|
||||||
$schnell_exists = false;
|
$schnell_exists = false;
|
||||||
foreach ( $models as $model ) {
|
foreach ( $models as $model ) {
|
||||||
if ( 'runware:100@1' === ( $model['id'] ?? '' ) ) {
|
if ( 'runware:100@1' === ( $model['id'] ?? '' ) ) {
|
||||||
$schnell_exists = true;
|
$schnell_exists = true;
|
||||||
$this->assertSame( 'FLUX.1 schnell (Runware)', $model['name'] );
|
|
||||||
$this->assertSame( 'runware', $model['provider'] );
|
$this->assertSame( 'runware', $model['provider'] );
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -59,28 +281,12 @@ class RunwareModelCatalogTest extends BaseTestCase {
|
|||||||
$this->assertTrue( $schnell_exists, 'Fallback should include FLUX.1 schnell' );
|
$this->assertTrue( $schnell_exists, 'Fallback should include FLUX.1 schnell' );
|
||||||
}
|
}
|
||||||
|
|
||||||
/** is_stale returns true if never synced. */
|
/** Fallback list is never empty (even with no key configured). */
|
||||||
public function test_is_stale_never_synced(): void {
|
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
|
||||||
$this->assertTrue( $catalog->is_stale() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/** get_last_synced_at returns null if never synced. */
|
|
||||||
public function test_get_last_synced_at_never(): void {
|
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
|
||||||
$this->assertNull( $catalog->get_last_synced_at() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fallback list is never empty. */
|
|
||||||
public function test_get_models_never_empty(): void {
|
public function test_get_models_never_empty(): void {
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$models = $catalog->get_models();
|
||||||
// Even with no cache and failed sync, get_models should return the fallback.
|
|
||||||
$models = $catalog->get_models();
|
|
||||||
$this->assertIsArray( $models );
|
|
||||||
$this->assertNotEmpty( $models );
|
$this->assertNotEmpty( $models );
|
||||||
|
|
||||||
// All models should have required fields.
|
|
||||||
foreach ( $models as $model ) {
|
foreach ( $models as $model ) {
|
||||||
$this->assertArrayHasKey( 'id', $model );
|
$this->assertArrayHasKey( 'id', $model );
|
||||||
$this->assertArrayHasKey( 'name', $model );
|
$this->assertArrayHasKey( 'name', $model );
|
||||||
@@ -89,13 +295,24 @@ class RunwareModelCatalogTest extends BaseTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fallback models have correct pricing. */
|
/** All fallback models carry the image_generation capability. */
|
||||||
|
public function test_fallback_models_have_image_generation_capability(): void {
|
||||||
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$models = $catalog->get_models();
|
||||||
|
|
||||||
|
foreach ( $models as $model ) {
|
||||||
|
$this->assertIsArray( $model['capabilities'] );
|
||||||
|
$this->assertContains( 'image_generation', $model['capabilities'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback schnell and dev have correct pricing. */
|
||||||
public function test_fallback_models_have_pricing(): void {
|
public function test_fallback_models_have_pricing(): void {
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
$models = $catalog->get_models();
|
$models = $catalog->get_models();
|
||||||
|
|
||||||
$schnell = null;
|
$schnell = null;
|
||||||
$dev = null;
|
$dev = null;
|
||||||
foreach ( $models as $model ) {
|
foreach ( $models as $model ) {
|
||||||
if ( 'runware:100@1' === $model['id'] ) {
|
if ( 'runware:100@1' === $model['id'] ) {
|
||||||
$schnell = $model;
|
$schnell = $model;
|
||||||
@@ -107,83 +324,98 @@ class RunwareModelCatalogTest extends BaseTestCase {
|
|||||||
|
|
||||||
$this->assertNotNull( $schnell );
|
$this->assertNotNull( $schnell );
|
||||||
$this->assertEqualsWithDelta( 0.0006, $schnell['cost_per_image'], 0.000001 );
|
$this->assertEqualsWithDelta( 0.0006, $schnell['cost_per_image'], 0.000001 );
|
||||||
|
|
||||||
$this->assertNotNull( $dev );
|
$this->assertNotNull( $dev );
|
||||||
$this->assertEqualsWithDelta( 0.02, $dev['cost_per_image'], 0.000001 );
|
$this->assertEqualsWithDelta( 0.02, $dev['cost_per_image'], 0.000001 );
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fallback models all have 'image_generation' capability. */
|
// ── Happy path (sync → cache → get_models) ───────────────────────────────
|
||||||
public function test_fallback_models_have_capabilities(): void {
|
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
|
||||||
$models = $catalog->get_models();
|
|
||||||
|
|
||||||
foreach ( $models as $model ) {
|
/** Successful sync caches models; get_models returns them from cache. */
|
||||||
$this->assertIsArray( $model['capabilities'] );
|
|
||||||
$this->assertContains( 'image_generation', $model['capabilities'] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Happy path: successful sync caches models and get_models returns them. */
|
|
||||||
public function test_sync_success_caches_and_returns_models(): void {
|
public function test_sync_success_caches_and_returns_models(): void {
|
||||||
// The mocked API response body — models must have taskType=imageInference
|
$fixture_body = file_get_contents( __DIR__ . '/../../fixtures/runware-model-search-page1.json' );
|
||||||
// so normalize_models() doesn't filter them out.
|
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
|
||||||
$fake_body = json_encode(
|
|
||||||
array(
|
$this->stub_http_helpers();
|
||||||
'data' => array(
|
Functions\when( 'wp_remote_post' )->justReturn(
|
||||||
array(
|
$this->make_http_response( 200, $fixture_body )
|
||||||
'id' => 'test-model-1',
|
|
||||||
'name' => 'Test Model 1',
|
|
||||||
'taskType' => 'imageInference',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'test-model-2',
|
|
||||||
'name' => 'Test Model 2',
|
|
||||||
'taskType' => 'imageInference',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$fake_response = array(
|
|
||||||
'response' => array( 'code' => 200 ),
|
|
||||||
'body' => $fake_body,
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn( $fake_response );
|
|
||||||
// Alias the WP response-parsing helpers so they work against our array.
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
|
|
||||||
function ( $r ) { return (int) ( $r['response']['code'] ?? 0 ); }
|
|
||||||
);
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->alias(
|
|
||||||
function ( $r ) { return (string) ( $r['body'] ?? '' ); }
|
|
||||||
);
|
|
||||||
Functions\when( 'is_wp_error' )->justReturn( false );
|
|
||||||
|
|
||||||
// Shared options store so get_option/update_option stay in sync
|
|
||||||
// within this test. The API key must be non-empty for sync() to proceed.
|
|
||||||
$store = array( 'prautoblogger_runware_api_key' => 'test-api-key-plain' );
|
|
||||||
Functions\when( 'get_option' )->alias(
|
|
||||||
function ( $key, $default = false ) use ( &$store ) {
|
|
||||||
return $store[ $key ] ?? $default;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'update_option' )->alias(
|
|
||||||
function ( $key, $value ) use ( &$store ) {
|
|
||||||
$store[ $key ] = $value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync should succeed: API key present, HTTP 200, 2 imageInference models.
|
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
$result = $catalog->sync();
|
$result = $catalog->sync();
|
||||||
$this->assertTrue( $result, 'Sync should return true on HTTP 200 with valid models' );
|
|
||||||
|
$this->assertTrue( $result, 'Sync should return true on HTTP 200 with valid fixture' );
|
||||||
|
|
||||||
// get_models() should now return the synced list from cache.
|
|
||||||
$models = $catalog->get_models();
|
$models = $catalog->get_models();
|
||||||
$this->assertIsArray( $models );
|
$this->assertIsArray( $models );
|
||||||
$this->assertCount( 2, $models );
|
// Fixture has 3 t2i models (schnell, dev, klein 9B).
|
||||||
$this->assertSame( 'test-model-1', $models[0]['id'] );
|
$this->assertCount( 3, $models, 'get_models should return the 3 cached t2i models' );
|
||||||
$this->assertSame( 'test-model-2', $models[1]['id'] );
|
|
||||||
|
$ids = array_column( $models, 'id' );
|
||||||
|
$this->assertContains( 'runware:100@1', $ids );
|
||||||
|
$this->assertContains( 'runware:101@1', $ids );
|
||||||
|
$this->assertContains( 'runware:400@2', $ids );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synced models have pricing merged from the pricing class. */
|
||||||
|
public function test_synced_models_have_pricing_merged(): void {
|
||||||
|
$fixture_body = file_get_contents( __DIR__ . '/../../fixtures/runware-model-search-page1.json' );
|
||||||
|
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
|
||||||
|
|
||||||
|
$this->stub_http_helpers();
|
||||||
|
Functions\when( 'wp_remote_post' )->justReturn(
|
||||||
|
$this->make_http_response( 200, $fixture_body )
|
||||||
|
);
|
||||||
|
|
||||||
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$catalog->sync();
|
||||||
|
$models = $catalog->get_models();
|
||||||
|
|
||||||
|
$schnell = null;
|
||||||
|
foreach ( $models as $m ) {
|
||||||
|
if ( 'runware:100@1' === $m['id'] ) {
|
||||||
|
$schnell = $m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertNotNull( $schnell );
|
||||||
|
$this->assertEqualsWithDelta( 0.0006, (float) $schnell['cost_per_image'], 0.000001 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stale / absent cache tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** is_stale returns true if never synced. */
|
||||||
|
public function test_is_stale_never_synced(): void {
|
||||||
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$this->assertTrue( $catalog->is_stale() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** is_stale returns false for a fresh cache (synced < 24h ago). */
|
||||||
|
public function test_is_stale_fresh_cache(): void {
|
||||||
|
$now = 1704067200;
|
||||||
|
$this->options['prautoblogger_runware_model_cache_updated_at'] = $now - 3600; // 1h ago.
|
||||||
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$this->assertFalse( $catalog->is_stale() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** is_stale returns true when cache is older than 24h. */
|
||||||
|
public function test_is_stale_old_cache(): void {
|
||||||
|
$now = 1704067200;
|
||||||
|
$this->options['prautoblogger_runware_model_cache_updated_at'] = $now - 90000; // > 24h ago.
|
||||||
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$this->assertTrue( $catalog->is_stale() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** get_last_synced_at returns null if never synced. */
|
||||||
|
public function test_get_last_synced_at_never(): void {
|
||||||
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$this->assertNull( $catalog->get_last_synced_at() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sync with no API key returns false without recording a failure. */
|
||||||
|
public function test_sync_no_api_key(): void {
|
||||||
|
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$result = $catalog->sync();
|
||||||
|
$this->assertFalse( $result );
|
||||||
|
// No key → not a transient failure, no cooldown recorded.
|
||||||
|
$this->assertNull( $catalog->get_last_failure_at() );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user