diff --git a/CHANGELOG.md b/CHANGELOG.md index 316e7bc..945c3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/includes/core/class-cost-tracker.php b/includes/core/class-cost-tracker.php index c874cbf..db18cfc 100644 --- a/includes/core/class-cost-tracker.php +++ b/includes/core/class-cost-tracker.php @@ -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 ), ); } diff --git a/prautoblogger.php b/prautoblogger.php index 7bb644d..7cf49a9 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -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__ ) ); diff --git a/tests/unit/Core/CostTrackerTest.php b/tests/unit/Core/CostTrackerTest.php index f5278e6..0d720ae 100644 --- a/tests/unit/Core/CostTrackerTest.php +++ b/tests/unit/Core/CostTrackerTest.php @@ -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 ); + } }