v0.19.0 - Phase 2 admin M1: kanban board + live progress; cost-reporter SQL fix (#156)
M1 of the Editorial Record admin (Direction C, CEO-ratified 2026-06-12). Kanban dashboard (Generating/In Review/Published/Failed) as primary landing, AJAX live progress via status poller, setting-backed poll interval + published window, cost-reporter phpcs:ignore SQL fix, board column mapping tests. QA: REJECT-P1 (29a334b) -> remediated -> APPROVE-WITH-P2-NOTES (60aa449). Verdicts: qa-reviews/prautoblogger/2026-06-12-29a334b.md, 2026-06-12-60aa449.md Thread: convo/prautoblogger/threads/2026-06-pipeline-v2-phase2-admin/ seq 1-9 Agent-Session: cto-phase2-admin-m1-merge-20260612
This commit is contained in:
@@ -103,12 +103,17 @@ if ( ! defined( 'OBJECT' ) ) {
|
||||
/**
|
||||
* Minimal WP_Query stub for unit tests.
|
||||
*
|
||||
* IdeaScorer uses WP_Query to check for existing posts.
|
||||
* This stub returns empty results by default.
|
||||
* IdeaScorer uses WP_Query to check for existing posts with empty results.
|
||||
* Board data provider tests inject posts via WP_Query::$_test_posts_queue
|
||||
* (static FIFO): push arrays of WP_Post objects before instantiating the
|
||||
* provider, and each new WP_Query() call will dequeue one batch.
|
||||
*/
|
||||
if ( ! class_exists( 'WP_Query' ) ) {
|
||||
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
|
||||
class WP_Query {
|
||||
/** @var array<int, array> FIFO queue of post-arrays for tests. */
|
||||
public static $_test_posts_queue = [];
|
||||
|
||||
/** @var array */
|
||||
public $posts = [];
|
||||
/** @var int */
|
||||
@@ -117,7 +122,11 @@ if ( ! class_exists( 'WP_Query' ) ) {
|
||||
public $have_posts = false;
|
||||
|
||||
public function __construct( $args = [] ) {
|
||||
// No-op for testing — returns no posts.
|
||||
if ( ! empty( self::$_test_posts_queue ) ) {
|
||||
$this->posts = array_shift( self::$_test_posts_queue );
|
||||
$this->found_posts = count( $this->posts );
|
||||
$this->have_posts = $this->found_posts > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public function have_posts(): bool {
|
||||
@@ -165,3 +174,34 @@ if ( ! class_exists( 'WP_Error' ) ) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal WP_Post stub for unit tests.
|
||||
*
|
||||
* Board data provider tests return WP_Post instances from WP_Query.
|
||||
* Class defined here so instanceof checks in production code work correctly.
|
||||
*/
|
||||
if ( ! class_exists( 'WP_Post' ) ) {
|
||||
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
|
||||
class WP_Post {
|
||||
/** @var int */
|
||||
public $ID = 0;
|
||||
/** @var string */
|
||||
public $post_title = '';
|
||||
/** @var string */
|
||||
public $post_status = 'publish';
|
||||
/** @var string */
|
||||
public $post_date_gmt = '';
|
||||
/** @var string */
|
||||
public $post_modified_gmt = '';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function __construct( array $data = [] ) {
|
||||
foreach ( $data as $key => $value ) {
|
||||
$this->$key = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
292
tests/unit/Admin/BoardDataProviderTest.php
Normal file
292
tests/unit/Admin/BoardDataProviderTest.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Board_Data_Provider — column/state bucket mapping.
|
||||
*
|
||||
* Seeds mock DB results and WordPress post data, then asserts that each
|
||||
* board column receives the correct cards. No live DB required; uses Brain\Monkey
|
||||
* and a mock $wpdb.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class BoardDataProviderTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||
private $wpdb;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
|
||||
// Reset WP_Query test queue before each test.
|
||||
\WP_Query::$_test_posts_queue = [];
|
||||
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( string $name, $default = false ) {
|
||||
$map = array(
|
||||
'prautoblogger_board_published_window_days' => 7,
|
||||
'prautoblogger_board_poll_interval' => 5,
|
||||
);
|
||||
return $map[ $name ] ?? $default;
|
||||
}
|
||||
);
|
||||
|
||||
// Transient stub: by default no active run.
|
||||
Functions\when( 'get_transient' )->justReturn( false );
|
||||
|
||||
// Admin URL stub.
|
||||
Functions\when( 'admin_url' )->alias(
|
||||
function ( string $path = '' ) {
|
||||
return 'https://test.example.com/wp-admin/' . ltrim( $path, '/' );
|
||||
}
|
||||
);
|
||||
|
||||
// DAY_IN_SECONDS constant.
|
||||
if ( ! defined( 'DAY_IN_SECONDS' ) ) {
|
||||
define( 'DAY_IN_SECONDS', 86400 );
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\WP_Query::$_test_posts_queue = [];
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── Generating cards ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A running transient with status='running' yields a Generating card.
|
||||
*/
|
||||
public function test_generating_cards_from_transient(): void {
|
||||
Functions\when( 'get_transient' )->alias(
|
||||
function ( $key ) {
|
||||
if ( 'prautoblogger_generation_status' === $key ) {
|
||||
return array(
|
||||
'status' => 'running',
|
||||
'stage' => 'Drafting article…',
|
||||
'started' => time() - 60,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// No gen_log secondary runs needed.
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$cards = $provider->get_generating_cards();
|
||||
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 'Drafting article…', $cards[0]['stage_current'] );
|
||||
$this->assertSame( 'logs', $cards[0]['click_action'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* When the transient is idle, orphan gen_log rows produce Generating cards.
|
||||
*/
|
||||
public function test_generating_cards_from_gen_log(): void {
|
||||
Functions\when( 'get_transient' )->justReturn( false );
|
||||
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn(
|
||||
array(
|
||||
array(
|
||||
'run_id' => 'abc-123',
|
||||
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 120 ),
|
||||
'last_stage' => 'draft',
|
||||
'stage_count' => '3',
|
||||
'cost_total' => '0.005000',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$cards = $provider->get_generating_cards();
|
||||
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 'abc-123', $cards[0]['run_id'] );
|
||||
$this->assertSame( 'draft', $cards[0]['stage_current'] );
|
||||
$this->assertEqualsWithDelta( 0.005, $cards[0]['cost_total'], 0.0001 );
|
||||
}
|
||||
|
||||
/**
|
||||
* An idle transient + empty gen_log yields zero Generating cards.
|
||||
*/
|
||||
public function test_no_generating_cards_when_idle(): void {
|
||||
Functions\when( 'get_transient' )->justReturn( false );
|
||||
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$cards = $provider->get_generating_cards();
|
||||
|
||||
$this->assertCount( 0, $cards );
|
||||
}
|
||||
|
||||
// ── In Review cards ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Draft posts with the _prautoblogger_generated meta appear in In Review.
|
||||
*/
|
||||
public function test_in_review_cards_from_draft_posts(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 42;
|
||||
$post->post_title = 'Test Draft Post';
|
||||
$post->post_date_gmt = '2026-06-12 10:00:00';
|
||||
$post->post_status = 'draft';
|
||||
|
||||
// Inject post into WP_Query queue: first WP_Query() call returns this post.
|
||||
\WP_Query::$_test_posts_queue[] = array( $post );
|
||||
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
function ( $post_id, $key, $single ) use ( $post ) {
|
||||
if ( $post_id === $post->ID ) {
|
||||
if ( '_prautoblogger_run_id' === $key ) {
|
||||
return 'run-draft-999';
|
||||
}
|
||||
if ( '_prautoblogger_total_cost' === $key ) {
|
||||
return '0.012000';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'get_edit_post_link' )->justReturn(
|
||||
'https://test.example.com/wp-admin/post.php?post=42&action=edit'
|
||||
);
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$cards = $provider->get_in_review_cards();
|
||||
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 42, $cards[0]['post_id'] );
|
||||
$this->assertSame( 'Test Draft Post', $cards[0]['title'] );
|
||||
$this->assertSame( 'review', $cards[0]['click_action'] );
|
||||
$this->assertEqualsWithDelta( 0.012, $cards[0]['cost_total'], 0.0001 );
|
||||
}
|
||||
|
||||
// ── Published cards ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Published posts within the window appear in Published.
|
||||
*/
|
||||
public function test_published_cards_within_window(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 77;
|
||||
$post->post_title = 'Published Article';
|
||||
$post->post_date_gmt = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS );
|
||||
$post->post_status = 'publish';
|
||||
|
||||
// Inject post into WP_Query queue: first WP_Query() call returns this post.
|
||||
\WP_Query::$_test_posts_queue[] = array( $post );
|
||||
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
function ( $post_id, $key, $single ) use ( $post ) {
|
||||
if ( $post_id === $post->ID ) {
|
||||
if ( '_prautoblogger_run_id' === $key ) {
|
||||
return 'run-pub-77';
|
||||
}
|
||||
if ( '_prautoblogger_total_cost' === $key ) {
|
||||
return '0.020000';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'get_edit_post_link' )->justReturn(
|
||||
'https://test.example.com/wp-admin/post.php?post=77&action=edit'
|
||||
);
|
||||
Functions\when( 'get_permalink' )->justReturn( 'https://test.example.com/?p=77' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$cards = $provider->get_published_cards();
|
||||
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 77, $cards[0]['post_id'] );
|
||||
$this->assertSame( 'edit', $cards[0]['click_action'] );
|
||||
}
|
||||
|
||||
// ── Failed cards ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gen_log error rows with no post produce Failed cards.
|
||||
*/
|
||||
public function test_failed_cards_from_error_log(): void {
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn(
|
||||
array(
|
||||
array(
|
||||
'run_id' => 'fail-run-001',
|
||||
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 3600 ),
|
||||
'last_error' => 'OpenRouter rate limit exceeded',
|
||||
'last_stage' => 'draft',
|
||||
'call_count' => '2',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$cards = $provider->get_failed_cards();
|
||||
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 'fail-run-001', $cards[0]['run_id'] );
|
||||
$this->assertStringContainsString( 'rate limit', $cards[0]['error_message'] );
|
||||
$this->assertSame( 'logs', $cards[0]['click_action'] );
|
||||
}
|
||||
|
||||
// ── Full snapshot ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* get_board_snapshot() has_active_runs = true when Generating is non-empty.
|
||||
*/
|
||||
public function test_snapshot_has_active_runs_flag(): void {
|
||||
Functions\when( 'get_transient' )->alias(
|
||||
function ( $key ) {
|
||||
if ( 'prautoblogger_generation_status' === $key ) {
|
||||
return array(
|
||||
'status' => 'running',
|
||||
'stage' => 'Writing…',
|
||||
'started' => time(),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$snapshot = $provider->get_board_snapshot();
|
||||
|
||||
$this->assertTrue( $snapshot['has_active_runs'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* get_board_snapshot() has_active_runs = false when Generating is empty.
|
||||
*/
|
||||
public function test_snapshot_no_active_runs_when_idle(): void {
|
||||
Functions\when( 'get_transient' )->justReturn( false );
|
||||
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$snapshot = $provider->get_board_snapshot();
|
||||
|
||||
$this->assertFalse( $snapshot['has_active_runs'] );
|
||||
}
|
||||
}
|
||||
122
tests/unit/Core/CostReporterSqlBugFixTest.php
Normal file
122
tests/unit/Core/CostReporterSqlBugFixTest.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests verifying the SQL inline-comment bug fix in PRAutoBlogger_Cost_Reporter.
|
||||
*
|
||||
* The original code embedded PHP `// phpcs:ignore` annotations inside SQL string
|
||||
* literals, passing them to MySQL as literal comment text. These tests confirm the
|
||||
* SQL strings passed to $wpdb->prepare() no longer contain inline PHP comments.
|
||||
*
|
||||
* Kept separate from CostReporterTest to isolate the bug-fix regression signal.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class CostReporterSqlBugFixTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||
private $wpdb;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( $name, $default = false ) {
|
||||
return 'prautoblogger_monthly_budget_usd' === $name ? '50.00' : $default;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* get_daily_spend() must not pass inline PHP comments to prepare().
|
||||
*
|
||||
* Captures the SQL argument passed to $wpdb->prepare() and asserts it
|
||||
* contains no `// phpcs:ignore` text (which would be literal SQL, not PHP).
|
||||
*/
|
||||
public function test_get_daily_spend_sql_has_no_inline_comment(): void {
|
||||
$captured_sql = null;
|
||||
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( string $query ) use ( &$captured_sql ) {
|
||||
$captured_sql = $query;
|
||||
return 'prepared_query';
|
||||
}
|
||||
);
|
||||
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||
|
||||
$reporter = new \PRAutoBlogger_Cost_Reporter();
|
||||
$reporter->get_daily_spend( 30 );
|
||||
|
||||
$this->assertNotNull( $captured_sql, 'prepare() should have been called' );
|
||||
$this->assertStringNotContainsString(
|
||||
'// phpcs:ignore',
|
||||
$captured_sql,
|
||||
'get_daily_spend() SQL must not contain a PHP inline comment'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* get_spend_by_stage() must not pass inline PHP comments to prepare().
|
||||
*/
|
||||
public function test_get_spend_by_stage_sql_has_no_inline_comment(): void {
|
||||
$captured_sql = null;
|
||||
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( string $query ) use ( &$captured_sql ) {
|
||||
$captured_sql = $query;
|
||||
return 'prepared_query';
|
||||
}
|
||||
);
|
||||
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||
|
||||
$reporter = new \PRAutoBlogger_Cost_Reporter();
|
||||
$reporter->get_spend_by_stage( '2026-06-01', '2026-06-30' );
|
||||
|
||||
$this->assertNotNull( $captured_sql, 'prepare() should have been called' );
|
||||
$this->assertStringNotContainsString(
|
||||
'// phpcs:ignore',
|
||||
$captured_sql,
|
||||
'get_spend_by_stage() SQL must not contain a PHP inline comment'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the existing monthly spend query was already clean (regression guard).
|
||||
*/
|
||||
public function test_get_monthly_spend_sql_was_already_clean(): void {
|
||||
$captured_sql = null;
|
||||
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( string $query ) use ( &$captured_sql ) {
|
||||
$captured_sql = $query;
|
||||
return 'prepared_query';
|
||||
}
|
||||
);
|
||||
|
||||
$this->wpdb->method( 'get_var' )->willReturn( '5.00' );
|
||||
|
||||
$reporter = new \PRAutoBlogger_Cost_Reporter();
|
||||
$reporter->get_monthly_spend();
|
||||
|
||||
$this->assertNotNull( $captured_sql );
|
||||
$this->assertStringNotContainsString(
|
||||
'// phpcs:ignore',
|
||||
$captured_sql,
|
||||
'get_monthly_spend() SQL must not contain a PHP inline comment'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user