R1: move phpcs:ignore out of SQL string in class-analysis-prompts.php:81
(same pattern as v0.18.2 cost-reporter fix). GA4 self-improvement loop
has returned empty context on every pipeline run since v0.16.0 — this
restores get_performance_context() to the LLM. Sibling occurrence also
fixed in class-post-assembler.php:112.
R2(a): update_generation_stage() now renews STATUS_TTL on every stage call
so the transient outlasts long LLM calls during background runs.
R2(b): on_ajax_generation_status() detects lock-held-but-transient-gone
when lock age > STATUS_TTL (= Hostinger background-process kill).
Marks stuck run failed, releases lock, returns status:error with
'infrastructure timeout' message. Daily reaper remains the backstop.
R3: when transient absent but lock held within TTL, return status:running
(with started_at from lock) instead of status:idle.
Executor split: class-executor.php was 351 lines (>300). AJAX generation
handlers extracted to new class-generation-status-poller.php (260 lines);
executor now 226 lines. Public hook wiring unchanged via delegation.
Generation_Lock gains get_acquired_at() for R2b/R3 lock-age checks.
Tests: AnalysisPromptsTest (regression SQL + behavioral context shaping),
GenerationStatusPollerTest (R2a TTL renewal, R2b abort, R3 running,
idle path).
Closes: R1 (analysis-prompts SQL), R2 (manual-run resilience), R3 (honest status)
Version: 0.18.3
Agent-Session: prab-v0183-20260612
225 lines
8.5 KiB
PHP
225 lines
8.5 KiB
PHP
<?php
|
|
/**
|
|
* Tests for PRAutoBlogger_Generation_Status_Poller (R2/R3 resilience).
|
|
*
|
|
* Covers:
|
|
* - R2(a): update_generation_stage() renews the transient TTL on every call.
|
|
* - R2(b): on_ajax_generation_status() detects lock-held-but-transient-gone
|
|
* when lock age > STATUS_TTL and returns status:error with an infrastructure
|
|
* timeout message.
|
|
* - R3: on_ajax_generation_status() returns status:running (with started_at)
|
|
* when the transient is absent but the lock is held within TTL.
|
|
*
|
|
* @package PRAutoBlogger\Tests\Core
|
|
*/
|
|
|
|
namespace PRAutoBlogger\Tests\Core;
|
|
|
|
use PRAutoBlogger\Tests\BaseTestCase;
|
|
use Brain\Monkey\Functions;
|
|
|
|
class GenerationStatusPollerTest extends BaseTestCase {
|
|
|
|
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
|
private $wpdb;
|
|
|
|
/** @var array Captured wp_send_json_success payloads. */
|
|
private array $json_success = array();
|
|
|
|
/** @var array Captured wp_send_json_error payloads. */
|
|
private array $json_error = array();
|
|
|
|
/** @var array Captured transient writes: [key, value, ttl]. */
|
|
private array $transient_writes = array();
|
|
|
|
/** @var mixed Current transient value. */
|
|
private $transient_value = false;
|
|
|
|
/** @var int|null Mocked lock acquired_at timestamp (null = not held). */
|
|
private ?int $lock_acquired_at = null;
|
|
|
|
protected function setUp(): void {
|
|
parent::setUp();
|
|
|
|
$this->json_success = array();
|
|
$this->json_error = array();
|
|
$this->transient_writes = array();
|
|
$this->transient_value = false;
|
|
$this->lock_acquired_at = null;
|
|
|
|
$this->wpdb = $this->create_mock_wpdb();
|
|
$GLOBALS['wpdb'] = $this->wpdb;
|
|
|
|
// Stub nonce check and capability check (always pass in unit tests).
|
|
Functions\when( 'check_ajax_referer' )->justReturn( true );
|
|
Functions\when( 'current_user_can' )->justReturn( true );
|
|
|
|
// Transient stubs.
|
|
Functions\when( 'get_transient' )->alias( function( $key ) {
|
|
return $this->transient_value;
|
|
} );
|
|
Functions\when( 'set_transient' )->alias( function( $key, $value, $ttl ) {
|
|
$this->transient_writes[] = array( $key, $value, $ttl );
|
|
return true;
|
|
} );
|
|
|
|
// wp_send_json stubs — capture payload, mark test as "sent".
|
|
Functions\when( 'wp_send_json_success' )->alias( function( $data ) {
|
|
$this->json_success[] = $data;
|
|
} );
|
|
Functions\when( 'wp_send_json_error' )->alias( function( $data ) {
|
|
$this->json_error[] = $data;
|
|
} );
|
|
|
|
// Generation_Lock::get_acquired_at() — return our test value.
|
|
// We stub the static method via $wpdb since it does a DB query.
|
|
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
|
function ( $sql, ...$args ) {
|
|
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
|
}
|
|
);
|
|
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
|
function ( $sql ) {
|
|
// Return the lock timestamp when querying the lock option.
|
|
if ( false !== strpos( (string) $sql, 'prautoblogger_generation_lock' ) ) {
|
|
return null !== $this->lock_acquired_at ? (string) $this->lock_acquired_at : null;
|
|
}
|
|
return null;
|
|
}
|
|
);
|
|
|
|
// Run_State and Run_Context stubs — no DB tables in unit tests.
|
|
\PRAutoBlogger_Run_State::flush_cache();
|
|
Functions\when( 'get_option' )->justReturn( false );
|
|
Functions\when( 'delete_option' )->justReturn( true );
|
|
Functions\when( 'delete_transient' )->justReturn( true );
|
|
|
|
// Logger stub.
|
|
Functions\when( 'wp_date' )->returnArg();
|
|
}
|
|
|
|
protected function tearDown(): void {
|
|
\PRAutoBlogger_Run_State::flush_cache();
|
|
unset( $GLOBALS['wpdb'] );
|
|
parent::tearDown();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// R2(a): update_generation_stage() renews transient TTL
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* update_generation_stage() must renew the transient with STATUS_TTL on
|
|
* every call so the transient outlasts a long LLM call.
|
|
*/
|
|
public function test_update_generation_stage_renews_transient_ttl(): void {
|
|
// Seed an existing running transient.
|
|
$this->transient_value = array(
|
|
'status' => 'running',
|
|
'stage' => 'old stage',
|
|
'started' => time() - 30,
|
|
'last_updated' => time() - 30,
|
|
);
|
|
|
|
$poller = new \PRAutoBlogger_Generation_Status_Poller();
|
|
$poller->update_generation_stage( 'Analyzing sources...' );
|
|
|
|
$this->assertCount( 1, $this->transient_writes, 'set_transient must be called once' );
|
|
[ $key, $value, $ttl ] = $this->transient_writes[0];
|
|
$this->assertSame( \PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT, $key );
|
|
$this->assertSame( \PRAutoBlogger_Generation_Status_Poller::STATUS_TTL, $ttl,
|
|
'TTL must be renewed to STATUS_TTL on every stage update' );
|
|
$this->assertSame( 'Analyzing sources...', $value['stage'] );
|
|
}
|
|
|
|
/**
|
|
* update_generation_stage() is a no-op when the transient is absent
|
|
* (prevents spurious "running" writes after a run has completed).
|
|
*/
|
|
public function test_update_generation_stage_no_op_when_transient_absent(): void {
|
|
$this->transient_value = false; // absent.
|
|
|
|
$poller = new \PRAutoBlogger_Generation_Status_Poller();
|
|
$poller->update_generation_stage( 'Stage text' );
|
|
|
|
$this->assertEmpty( $this->transient_writes, 'set_transient must NOT be called when transient is absent' );
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// R3: transient absent, lock held within TTL → return status:running
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* R3: When the status transient has expired but the lock is held within
|
|
* STATUS_TTL, the poller must return status:running so the Generate Now
|
|
* button does not silently reset to idle during long background runs.
|
|
*/
|
|
public function test_missing_transient_but_fresh_lock_returns_running(): void {
|
|
$this->transient_value = false; // transient gone.
|
|
$this->lock_acquired_at = time() - 60; // lock held 60s — well within TTL.
|
|
|
|
// Stub _POST for the nonce.
|
|
$_POST = array( 'nonce' => 'fake' );
|
|
$poller = new \PRAutoBlogger_Generation_Status_Poller();
|
|
$poller->on_ajax_generation_status();
|
|
|
|
$this->assertCount( 1, $this->json_success, 'wp_send_json_success must be called once' );
|
|
$response = $this->json_success[0];
|
|
$this->assertSame( 'running', $response['status'],
|
|
'R3: status must be running when transient absent but lock held within TTL' );
|
|
$this->assertArrayHasKey( 'started', $response,
|
|
'R3: response must include started timestamp from lock' );
|
|
$this->assertSame( $this->lock_acquired_at, $response['started'],
|
|
'R3: started must equal the lock acquired_at timestamp' );
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// R2(b): transient absent, lock held > STATUS_TTL → abort + status:error
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* R2(b): When the status transient is absent and the lock has been held
|
|
* longer than STATUS_TTL, the poller must mark the run failed and return
|
|
* status:error with an "infrastructure timeout" message.
|
|
*/
|
|
public function test_missing_transient_with_expired_lock_marks_failed_and_returns_error(): void {
|
|
$this->transient_value = false;
|
|
$ttl = \PRAutoBlogger_Generation_Status_Poller::STATUS_TTL;
|
|
$this->lock_acquired_at = time() - ( $ttl + 60 ); // Lock held TTL+60s.
|
|
|
|
$_POST = array( 'nonce' => 'fake' );
|
|
$poller = new \PRAutoBlogger_Generation_Status_Poller();
|
|
$poller->on_ajax_generation_status();
|
|
|
|
$this->assertCount( 1, $this->json_success, 'wp_send_json_success must be called' );
|
|
$response = $this->json_success[0];
|
|
$this->assertSame( 'error', $response['status'],
|
|
'R2(b): status must be error when lock is expired-stale' );
|
|
$this->assertStringContainsString(
|
|
'infrastructure timeout',
|
|
strtolower( $response['message'] ?? '' ),
|
|
'R2(b): error message must mention infrastructure timeout'
|
|
);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Idle path: no transient, no lock
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* When both the transient and the lock are absent, the poller must
|
|
* return status:idle (normal rest state between runs).
|
|
*/
|
|
public function test_no_transient_no_lock_returns_idle(): void {
|
|
$this->transient_value = false;
|
|
$this->lock_acquired_at = null; // no lock.
|
|
|
|
$_POST = array( 'nonce' => 'fake' );
|
|
$poller = new \PRAutoBlogger_Generation_Status_Poller();
|
|
$poller->on_ajax_generation_status();
|
|
|
|
$this->assertCount( 1, $this->json_success );
|
|
$this->assertSame( 'idle', $this->json_success[0]['status'] );
|
|
}
|
|
}
|