Fix cost-tracker SELECT against canonical schema (v0.15.1)
The cost-preview probe in get_avg_tokens_for_stages() referenced input_tokens/output_tokens/timestamp, which don't exist. Canonical columns are prompt_tokens/completion_tokens/created_at. Empty-result fallback was masking the failure functionally, but the wpdb debug notice was visibly leaking to every admin visit of the AI Models tab. Adds regression tests that assert $wpdb->last_error after the call, so the next schema drift won't be masked the same way. Bug present since v0.11.0 (2026-04-23). Agent-Session: engineer-prautoblogger cost-tracker-schema-mismatch
This commit is contained in:
@@ -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/),
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project uses [Semantic Versioning](https://semver.org/).
|
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.14.0] - 2026-04-26
|
||||||
## [0.15.0] - 2026-04-26
|
## [0.15.0] - 2026-04-26
|
||||||
|
|
||||||
|
|||||||
@@ -235,19 +235,19 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cutoff_time = time() - ( $days * DAY_IN_SECONDS );
|
$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 ) );
|
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
|
||||||
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
|
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
|
||||||
$query = $wpdb->prepare(
|
$query = $wpdb->prepare(
|
||||||
"SELECT AVG(input_tokens) as avg_input,
|
"SELECT AVG(prompt_tokens) as avg_prompt,
|
||||||
AVG(output_tokens) as avg_output,
|
AVG(completion_tokens) as avg_completion,
|
||||||
COUNT(*) as sample_count
|
COUNT(*) as sample_count
|
||||||
FROM $table_name
|
FROM $table_name
|
||||||
WHERE stage IN ( $stage_list )
|
WHERE stage IN ( $stage_list )
|
||||||
AND timestamp >= %d",
|
AND created_at >= %s",
|
||||||
$cutoff_time
|
$cutoff_datetime
|
||||||
);
|
);
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- phpcs pragma above
|
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- phpcs pragma above
|
||||||
@@ -262,8 +262,8 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return array(
|
return array(
|
||||||
'avg_prompt_tokens' => (float) ( $result['avg_input'] ?? 0 ),
|
'avg_prompt_tokens' => (float) ( $result['avg_prompt'] ?? 0 ),
|
||||||
'avg_completion_tokens' => (float) ( $result['avg_output'] ?? 0 ),
|
'avg_completion_tokens' => (float) ( $result['avg_completion'] ?? 0 ),
|
||||||
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
|
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* Plugin Name: PRAutoBlogger
|
* Plugin Name: PRAutoBlogger
|
||||||
* Plugin URI: https://peptiderepo.com/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.
|
* 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 at least: 6.0
|
||||||
* Requires PHP: 7.4
|
* Requires PHP: 7.4
|
||||||
* Author: PeptideRepo
|
* Author: PeptideRepo
|
||||||
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
| Defined here so every file in the plugin can reference paths, versions,
|
| Defined here so every file in the plugin can reference paths, versions,
|
||||||
| and limits without magic strings.
|
| 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_DB_VERSION', '1.1.0' );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
|
|||||||
@@ -113,4 +113,139 @@ class CostTrackerTest extends BaseTestCase {
|
|||||||
// If we get here without exception, the method worked.
|
// If we get here without exception, the method worked.
|
||||||
$this->assertTrue( true );
|
$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 );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user