Compare commits
9 Commits
feat/runwa
...
claude/cos
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bac52d268 | ||
|
|
96f0248d81 | ||
|
|
19849a5eaf | ||
|
|
d9317ff567 | ||
|
|
30282035b5 | ||
|
|
6db6ffd7f5 | ||
|
|
1cdb41224b | ||
|
|
b2bc4be9c2 | ||
|
|
73ac6d12b4 |
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ['7.4', '8.1', '8.3']
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
coverage: none
|
||||
- name: Syntax check all PHP files
|
||||
# Exclude .github/mu-plugins — those run on the production server (PHP 8+)
|
||||
# and may use PHP 8+ syntax (union types etc.) that 7.4 rejects.
|
||||
# and may use PHP 8+ syntax (union types etc.) that older versions reject.
|
||||
run: |
|
||||
set -e
|
||||
find . -name '*.php' \
|
||||
@@ -44,14 +44,12 @@ jobs:
|
||||
phpcs:
|
||||
name: PHPCS (WordPress standards)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false # now strict after PHPCS cleanup in v0.8.3
|
||||
continue-on-error: false # strict after PHPCS cleanup in v0.8.3
|
||||
permissions:
|
||||
contents: write # allows phpcbf auto-fix commits back to the PR branch
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Check out the actual branch so phpcbf can push fixes back.
|
||||
# github.head_ref is set on PR events; github.ref_name on push events.
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup PHP
|
||||
@@ -62,10 +60,7 @@ jobs:
|
||||
tools: composer:v2, phpcs
|
||||
- name: Install WordPress Coding Standards
|
||||
run: |
|
||||
# Allow the PHPCS composer-installer plugin (auto-registers standards).
|
||||
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
||||
# WPCS 3.x requires PHPCSExtra (for the Universal.* and NormalizedArrays.* sniffs)
|
||||
# AND phpcsutils. Installing the installer itself lets it auto-register the standards path.
|
||||
composer global require --no-interaction \
|
||||
"dealerdirect/phpcodesniffer-composer-installer:^1.0" \
|
||||
"wp-coding-standards/wpcs:^3.0" \
|
||||
@@ -101,7 +96,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ['7.4', '8.1', '8.3']
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
|
||||
15
.github/workflows/deploy.yml
vendored
15
.github/workflows/deploy.yml
vendored
@@ -61,9 +61,17 @@ jobs:
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
|
||||
smoke:
|
||||
name: Smoke tests (staging)
|
||||
needs: deploy-staging
|
||||
uses: peptiderepo/peptide-e2e/.github/workflows/smoke.yml@main
|
||||
secrets:
|
||||
STAGING_WP_ADMIN_USER: ${{ secrets.STAGING_WP_ADMIN_USER }}
|
||||
STAGING_WP_ADMIN_PASSWORD: ${{ secrets.STAGING_WP_ADMIN_PASSWORD }}
|
||||
|
||||
deploy-production:
|
||||
name: Deploy to Production
|
||||
needs: [validate, deploy-staging]
|
||||
needs: [validate, deploy-staging, smoke]
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
@@ -103,3 +111,8 @@ jobs:
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
- name: Deploy summary
|
||||
run: |
|
||||
echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit:** \`${GITHUB_SHA::7}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Deployed to:** peptiderepo.com" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -5,6 +5,13 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.15.1] - 2026-05-14
|
||||
|
||||
### Fixed
|
||||
- Cost preview in AI Models settings tab no longer prints a WordPress database
|
||||
error notice; query now uses canonical column names (`prompt_tokens`,
|
||||
`completion_tokens`, `created_at`). Bug present since v0.11.0 (2026-04-23).
|
||||
|
||||
## [0.14.0] - 2026-04-26
|
||||
## [0.15.0] - 2026-04-26
|
||||
|
||||
|
||||
@@ -258,7 +258,6 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'default' => '1',
|
||||
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
|
||||
),
|
||||
);
|
||||
array(
|
||||
'id' => 'prautoblogger_runware_model_catalog',
|
||||
'label' => __( 'Runware Model Catalog', 'prautoblogger' ),
|
||||
@@ -266,6 +265,6 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'section' => 'prautoblogger_images',
|
||||
'description' => __( 'Live model catalog sync status and on-demand refresh.', 'prautoblogger' ),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,19 +235,19 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
);
|
||||
}
|
||||
|
||||
$cutoff_time = time() - ( $days * DAY_IN_SECONDS );
|
||||
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
|
||||
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
$cutoff_datetime = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
|
||||
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
|
||||
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
|
||||
$query = $wpdb->prepare(
|
||||
"SELECT AVG(input_tokens) as avg_input,
|
||||
AVG(output_tokens) as avg_output,
|
||||
"SELECT AVG(prompt_tokens) as avg_prompt,
|
||||
AVG(completion_tokens) as avg_completion,
|
||||
COUNT(*) as sample_count
|
||||
FROM $table_name
|
||||
WHERE stage IN ( $stage_list )
|
||||
AND timestamp >= %d",
|
||||
$cutoff_time
|
||||
AND created_at >= %s",
|
||||
$cutoff_datetime
|
||||
);
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- phpcs pragma above
|
||||
@@ -262,8 +262,8 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
}
|
||||
|
||||
return array(
|
||||
'avg_prompt_tokens' => (float) ( $result['avg_input'] ?? 0 ),
|
||||
'avg_completion_tokens' => (float) ( $result['avg_output'] ?? 0 ),
|
||||
'avg_prompt_tokens' => (float) ( $result['avg_prompt'] ?? 0 ),
|
||||
'avg_completion_tokens' => (float) ( $result['avg_completion'] ?? 0 ),
|
||||
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
|
||||
5
patchwork.json
Normal file
5
patchwork.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"redefinable-internals": [
|
||||
"time"
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* Plugin Name: PRAutoBlogger
|
||||
* Plugin URI: https://peptiderepo.com/prautoblogger
|
||||
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
|
||||
* Version: 0.15.0
|
||||
* Version: 0.15.1
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: PeptideRepo
|
||||
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
| Defined here so every file in the plugin can reference paths, versions,
|
||||
| and limits without magic strings.
|
||||
*/
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.15.0' );
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.15.1' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
|
||||
@@ -63,6 +63,9 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) {
|
||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' );
|
||||
}
|
||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER' ) ) {
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'runware' );
|
||||
}
|
||||
|
||||
// Default image style suffix used by ImagePromptBuilder and settings UI.
|
||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
|
||||
|
||||
@@ -113,4 +113,139 @@ class CostTrackerTest extends BaseTestCase {
|
||||
// If we get here without exception, the method worked.
|
||||
$this->assertTrue( true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: get_avg_tokens_for_stages must return the documented zero
|
||||
* shape when there is no generation history, without leaving a $wpdb
|
||||
* error behind.
|
||||
*
|
||||
* Load-bearing: asserting $wpdb->last_error === '' alongside the shape
|
||||
* is what would have caught the v0.11.0 column-name drift. The empty-
|
||||
* result fallback silently masked the failure from shape-only tests.
|
||||
*/
|
||||
public function test_get_avg_tokens_returns_zero_shape_when_no_history(): void {
|
||||
$captured_query = '';
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( $sql, ...$args ) use ( &$captured_query ) {
|
||||
$captured_query = $sql;
|
||||
return $sql; // Simulate prepared SQL passthrough.
|
||||
}
|
||||
);
|
||||
// Empty result row — fallback branch.
|
||||
$this->wpdb->method( 'get_row' )->willReturn( null );
|
||||
|
||||
$tracker = new \PRAutoBlogger_Cost_Tracker();
|
||||
$result = $tracker->get_avg_tokens_for_stages( [ 'analysis' ], 30 );
|
||||
|
||||
$this->assertSame(
|
||||
[
|
||||
'avg_prompt_tokens' => 0.0,
|
||||
'avg_completion_tokens' => 0.0,
|
||||
'sample_size' => 0,
|
||||
],
|
||||
$result
|
||||
);
|
||||
$this->assertSame( '', $this->wpdb->last_error, 'wpdb->last_error must be empty after the query' );
|
||||
|
||||
// Schema-drift guard: query must reference canonical columns, not the
|
||||
// pre-v0.15.1 names that don't exist on wp_prautoblogger_generation_log.
|
||||
$this->assertStringContainsString( 'prompt_tokens', $captured_query );
|
||||
$this->assertStringContainsString( 'completion_tokens', $captured_query );
|
||||
$this->assertStringContainsString( 'created_at', $captured_query );
|
||||
$this->assertStringNotContainsString( 'input_tokens', $captured_query );
|
||||
$this->assertStringNotContainsString( 'output_tokens', $captured_query );
|
||||
// The bug query had `AND timestamp >= %d`; canonical query uses
|
||||
// `AND created_at >= %s`. Guard against the column reference, not the
|
||||
// bare word (a SQL comment in future code could legitimately mention
|
||||
// "timestamp" in prose).
|
||||
$this->assertStringNotContainsString( 'timestamp >=', $captured_query );
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: with seeded rows the method returns real averages and the
|
||||
* canonical SELECT aliases (avg_prompt / avg_completion) are extracted
|
||||
* correctly.
|
||||
*/
|
||||
public function test_get_avg_tokens_returns_real_averages_with_seeded_rows(): void {
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( $sql ) {
|
||||
return $sql;
|
||||
}
|
||||
);
|
||||
// Simulate three rows averaged: prompt_tokens=100, completion_tokens=50.
|
||||
$this->wpdb->method( 'get_row' )->willReturn(
|
||||
[
|
||||
'avg_prompt' => '100.0000',
|
||||
'avg_completion' => '50.0000',
|
||||
'sample_count' => '3',
|
||||
]
|
||||
);
|
||||
|
||||
$tracker = new \PRAutoBlogger_Cost_Tracker();
|
||||
$result = $tracker->get_avg_tokens_for_stages( [ 'analysis' ], 30 );
|
||||
|
||||
$this->assertSame( 100.0, $result['avg_prompt_tokens'] );
|
||||
$this->assertSame( 50.0, $result['avg_completion_tokens'] );
|
||||
$this->assertSame( 3, $result['sample_size'] );
|
||||
$this->assertSame( '', $this->wpdb->last_error );
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: the days cutoff must be a MySQL DATETIME string (compared
|
||||
* against the canonical created_at column), not a unix int. Captured via
|
||||
* the prepare() callback so we can inspect the bound parameter.
|
||||
*/
|
||||
public function test_get_avg_tokens_respects_days_cutoff_as_datetime(): void {
|
||||
$captured_args = [];
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( $sql, ...$args ) use ( &$captured_args ) {
|
||||
$captured_args = $args;
|
||||
return $sql;
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_row' )->willReturn( null );
|
||||
|
||||
$tracker = new \PRAutoBlogger_Cost_Tracker();
|
||||
$tracker->get_avg_tokens_for_stages( [ 'analysis' ], 30 );
|
||||
|
||||
// Exactly one bound param: the DATETIME cutoff.
|
||||
$this->assertCount( 1, $captured_args );
|
||||
$this->assertIsString( $captured_args[0], 'Cutoff must be a DATETIME string, not a unix int' );
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/',
|
||||
$captured_args[0]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: the stage filter is propagated into the SQL. Calling with
|
||||
* a single stage should result in exactly one prepared %s placeholder
|
||||
* for the IN ( … ) list.
|
||||
*/
|
||||
public function test_get_avg_tokens_filters_by_stage(): void {
|
||||
$stage_prepare_calls = 0;
|
||||
$main_query = '';
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( $sql, ...$args ) use ( &$stage_prepare_calls, &$main_query ) {
|
||||
// The method calls $wpdb->prepare twice in two distinct shapes:
|
||||
// 1. per-stage placeholder prep (sql='%s', single arg)
|
||||
// 2. the main SELECT (sql contains 'SELECT', many tokens)
|
||||
if ( strpos( $sql, 'SELECT' ) !== false ) {
|
||||
$main_query = $sql;
|
||||
} else {
|
||||
++$stage_prepare_calls;
|
||||
}
|
||||
return $sql;
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_row' )->willReturn( null );
|
||||
|
||||
$tracker = new \PRAutoBlogger_Cost_Tracker();
|
||||
$tracker->get_avg_tokens_for_stages( [ 'analysis' ], 30 );
|
||||
|
||||
$this->assertSame( 1, $stage_prepare_calls, 'One stage → one per-stage prepare() call' );
|
||||
$this->assertStringContainsString( 'stage IN', $main_query );
|
||||
$this->assertStringContainsString( 'wp_prautoblogger_generation_log', $main_query );
|
||||
$this->assertSame( '', $this->wpdb->last_error );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,52 +125,61 @@ class RunwareModelCatalogTest extends BaseTestCase {
|
||||
|
||||
/** Happy path: successful sync caches models and get_models returns them. */
|
||||
public function test_sync_success_caches_and_returns_models(): void {
|
||||
// Mock a valid Runware API response.
|
||||
Functions\when( 'wp_remote_post' )->justReturn(
|
||||
// The mocked API response body — models must have taskType=imageInference
|
||||
// so normalize_models() doesn't filter them out.
|
||||
$fake_body = json_encode(
|
||||
array(
|
||||
'response' => array( 'code' => 200 ),
|
||||
'body' => wp_json_encode(
|
||||
'data' => array(
|
||||
array(
|
||||
'data' => array(
|
||||
array(
|
||||
'id' => 'test-model-1',
|
||||
'name' => 'Test Model 1',
|
||||
'cost' => 0.001,
|
||||
'provider' => 'runware',
|
||||
),
|
||||
array(
|
||||
'id' => 'test-model-2',
|
||||
'name' => 'Test Model 2',
|
||||
'cost' => 0.002,
|
||||
'provider' => 'runware',
|
||||
),
|
||||
),
|
||||
)
|
||||
'id' => 'test-model-1',
|
||||
'name' => 'Test Model 1',
|
||||
'taskType' => 'imageInference',
|
||||
),
|
||||
array(
|
||||
'id' => 'test-model-2',
|
||||
'name' => 'Test Model 2',
|
||||
'taskType' => 'imageInference',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Mock the API key to exist.
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
static $options = array();
|
||||
if ( 'prautoblogger_runware_api_key' === $key ) {
|
||||
return 'test-api-key';
|
||||
$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;
|
||||
}
|
||||
return $options[ $key ] ?? $default;
|
||||
} );
|
||||
);
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $key, $value ) use ( &$store ) {
|
||||
$store[ $key ] = $value;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'update_option' )->alias( function ( $key, $value ) {
|
||||
static $options = array();
|
||||
$options[ $key ] = $value;
|
||||
return true;
|
||||
} );
|
||||
|
||||
// Perform the sync.
|
||||
// Sync should succeed: API key present, HTTP 200, 2 imageInference models.
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$result = $catalog->sync();
|
||||
$this->assertTrue( $result, 'Sync should return true on success' );
|
||||
$this->assertTrue( $result, 'Sync should return true on HTTP 200 with valid models' );
|
||||
|
||||
// Verify get_models returns the synced models (not fallback).
|
||||
// get_models() should now return the synced list from cache.
|
||||
$models = $catalog->get_models();
|
||||
$this->assertIsArray( $models );
|
||||
$this->assertCount( 2, $models );
|
||||
|
||||
@@ -16,6 +16,18 @@ use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class ImageModelRegistryTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Stub WP cache functions so the catalog class falls back to the
|
||||
* hardcoded model list without hitting get_option or update_option.
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
// Catalog checks for cached data; return false → falls back to hardcoded list.
|
||||
\Brain\Monkey\Functions\when( 'get_option' )->justReturn( false );
|
||||
// Catalog writes cache on sync; stub so it doesn't throw in tests.
|
||||
\Brain\Monkey\Functions\when( 'update_option' )->justReturn( true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Every entry returned by get_models() must carry a non-empty id and
|
||||
* a provider of 'runware' or 'openrouter' (Cloudflare Workers AI was
|
||||
|
||||
Reference in New Issue
Block a user