feat(M3): re-run engine — replay + rebuild as chained-cron jobs under CPO guardrails

Two operator primitives, both queued (never synchronous — CPO hard
constraint), all mutations in the cron handler after re-validating
eligibility UNDER the generation lock:

- Replay (Stage_Replay + on_replay_job): one governed chat call from the
  latest edited input fork. options_from_body() is the exact inverse of
  build_body()+apply_reasoning_budget() (headroom subtracted back out;
  reasoning pinned explicitly so global-setting drift can't reshape a
  replay). Goes through send_chat_completion -> cost governor reserves
  against the SAME run row (guardrail 4), retry/empty-guard machinery,
  B1 recorder. Fresh output lands in the stage snapshot; downstream
  stale-marked (guardrail 3); human_modified set at execution entry
  (guardrail 2).
- Rebuild (on_rebuild_job): demote target+downstream to pending, re-enter
  Article_Worker with the idea from the stored seed; upstream done stages
  reuse (never re-charged), demoted stages rebuild prompts from CURRENT
  snapshots — how an upstream edit propagates. Decisions recorded while
  rebuilding a human-modified item are flagged.
- Rerun_Eligibility: published/future/private = frozen (guardrail 5,
  server-side); executing runs blocked; editable stages = writer chat
  stages (outline/draft/polish); chain-order downstream sets.
- Run_State::reopen(): atomic terminal->running gate, ceiling
  re-snapshotted from the current setting, accumulated spend NEVER reset.
- Publisher: re-run output refreshes the existing UNPUBLISHED post
  (status preserved — re-runs never publish); published posts untouched.
- Article_Worker persists the exact idea payload as a seed (INSERT-once)
  so rebuilds can't mis-derive item_key and duplicate a post.
- Rerun_Job_Support: queue/lock/budget/status-transient plumbing; jobs
  ride the M1 status transient so the board shows queued->pickup->result.

Agent-Session: engineer-prab-m3-20260612
This commit is contained in:
peptiderepo
2026-06-12 13:15:50 +00:00
parent aaf1b4274e
commit 38500b2781
14 changed files with 1705 additions and 5 deletions

View File

@@ -0,0 +1,134 @@
<?php
/**
* Tests for the v0.20.0 Publisher refresh-unpublished behavior.
*
* A re-run's regenerated content must land on the existing UNPUBLISHED
* post (idempotency previously skip-returned and silently discarded
* re-run output); published posts remain frozen and untouched (CPO
* guardrail 5 enforced at the data layer).
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class PublisherRerunUpdateTest extends BaseTestCase {
private \PRAutoBlogger_Article_Idea $idea;
private \PRAutoBlogger_Editorial_Review $review;
/**
* Args captured from wp_update_post().
*
* @var array|null
*/
private $updated_args = null;
/**
* Meta captured from update_post_meta().
*
* @var array<string, mixed>
*/
private array $updated_meta = array();
protected function setUp(): void {
parent::setUp();
$this->idea = new \PRAutoBlogger_Article_Idea( $this->get_article_idea_fixture() );
$this->review = new \PRAutoBlogger_Editorial_Review( $this->get_editorial_review_fixture() );
$this->stub_get_option( array() );
Functions\when( 'sanitize_text_field' )->returnArg();
Functions\when( 'wp_kses_post' )->returnArg();
Functions\when( 'wp_strip_all_tags' )->alias(
static function ( $text ) {
return trim( strip_tags( (string) $text ) );
}
);
Functions\when( 'is_wp_error' )->justReturn( false );
Functions\when( 'apply_filters' )->returnArg( 2 );
$this->updated_args = null;
$this->updated_meta = array();
Functions\when( 'wp_update_post' )->alias(
function ( $args ) {
$this->updated_args = (array) $args;
return $args['ID'] ?? 0;
}
);
Functions\when( 'update_post_meta' )->alias(
function ( $post_id, $key, $value ) {
$this->updated_meta[ (string) $key ] = $value;
return true;
}
);
// find_existing_post() resolves post 55 for this (run, idea).
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'prepare' )->willReturn( 'prepared' );
$wpdb->method( 'get_var' )->willReturn( '55' );
$GLOBALS['wpdb'] = $wpdb;
}
protected function tearDown(): void {
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/** Stub get_post for post 55 with a status. */
private function stub_existing_post( string $status ): void {
$post = new \WP_Post();
$post->ID = 55;
$post->post_status = $status;
Functions\when( 'get_post' )->justReturn( $post );
}
/**
* Re-run output refreshes the existing draft: content + title are
* updated, verdict meta refreshed — and the post status is NEVER
* touched (a re-run must not publish; publication stays an explicit
* operator action).
*/
public function test_rerun_refreshes_existing_draft_without_status_change(): void {
$this->stub_existing_post( 'draft' );
Functions\when( 'wp_insert_post' )->alias(
static function () {
throw new \RuntimeException( 'wp_insert_post must not be called for an existing post' );
}
);
$publisher = new \PRAutoBlogger_Publisher();
$post_id = $publisher->save_as_draft( '<p>Regenerated content</p>', $this->idea, $this->review, 'run-1' );
$this->assertSame( 55, $post_id );
$this->assertIsArray( $this->updated_args );
$this->assertSame( 55, $this->updated_args['ID'] );
$this->assertStringContainsString( 'Regenerated content', (string) $this->updated_args['post_content'] );
$this->assertArrayNotHasKey( 'post_status', $this->updated_args );
$this->assertSame( $this->review->get_verdict(), $this->updated_meta['_prautoblogger_editor_verdict'] );
$this->assertArrayHasKey( '_prautoblogger_generated_at', $this->updated_meta );
}
/**
* Published post: frozen — returned untouched, no wp_update_post, no
* meta writes, no duplicate insert (guardrail 5 at the data layer).
*/
public function test_published_post_is_never_updated(): void {
$this->stub_existing_post( 'publish' );
Functions\when( 'wp_insert_post' )->alias(
static function () {
throw new \RuntimeException( 'wp_insert_post must not be called for an existing post' );
}
);
$publisher = new \PRAutoBlogger_Publisher();
$post_id = $publisher->save_as_draft( '<p>Regenerated content</p>', $this->idea, $this->review, 'run-1' );
$this->assertSame( 55, $post_id );
$this->assertNull( $this->updated_args );
$this->assertSame( array(), $this->updated_meta );
}
}

View File

@@ -0,0 +1,182 @@
<?php
/**
* Tests for PRAutoBlogger_Rerun_Eligibility (v0.20.0, M3).
*
* Locks the published-post lockout (CPO guardrail 5 incl. future/private),
* the actively-executing-run block, the editable-stage policy, and the
* downstream/rebuild set computation that drives stale marking.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class RerunEligibilityTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
*/
private $wpdb;
protected function setUp(): void {
parent::setUp();
$this->wpdb = $this->create_mock_wpdb();
$this->wpdb->options = 'wp_options';
$GLOBALS['wpdb'] = $this->wpdb;
$this->wpdb->method( 'prepare' )->willReturnCallback(
static function ( $sql, ...$args ) {
return $sql . ' /* ' . implode( ',', array_map( 'strval', array_filter( $args, 'is_scalar' ) ) ) . ' */';
}
);
\PRAutoBlogger_Run_State::flush_cache();
\PRAutoBlogger_Stage_Input_Store::flush_cache();
}
protected function tearDown(): void {
\PRAutoBlogger_Run_State::flush_cache();
\PRAutoBlogger_Stage_Input_Store::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* Wire the mock for: runs table exists, run row with given status,
* generation lock free.
*/
private function wire_run( string $run_status ): void {
$this->wpdb->method( 'get_var' )->willReturnCallback(
static function ( $sql ) {
$sql = (string) $sql;
if ( false !== strpos( $sql, 'SHOW TABLES' ) ) {
// Both runs + stage_inputs probes succeed.
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
return 'wp_prautoblogger_stage_inputs';
}
return 'wp_prautoblogger_runs';
}
return null; // Lock option absent -> lock free; seed absent.
}
);
$this->wpdb->method( 'get_row' )->willReturn(
array(
'run_id' => 'run-1',
'status' => $run_status,
'ceiling_usd' => '0.50',
)
);
}
/** Stub get_post to return a post with the given status. */
private function stub_post( string $status ): void {
$post = new \WP_Post();
$post->ID = 99;
$post->post_status = $status;
Functions\when( 'get_post' )->justReturn( $post );
}
/**
* Published, scheduled, and private posts are all frozen — no
* edit+rerun (CPO guardrail 5). Draft and pending are not.
*/
public function test_published_post_lockout_covers_all_frozen_statuses(): void {
foreach ( array( 'publish', 'future', 'private' ) as $frozen ) {
$post = new \WP_Post();
$post->post_status = $frozen;
$this->assertTrue( \PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ), "status {$frozen} must freeze" );
}
foreach ( array( 'draft', 'pending' ) as $open ) {
$post = new \WP_Post();
$post->post_status = $open;
$this->assertFalse( \PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ), "status {$open} must not freeze" );
}
// A run with no post yet is not frozen.
$this->assertFalse( \PRAutoBlogger_Rerun_Eligibility::post_frozen( null ) );
}
/**
* check() rejects a published post with the WP-editor redirect copy
* (server-side enforcement, not just hidden UI).
*/
public function test_check_rejects_published_post(): void {
$this->wire_run( 'done' );
$this->stub_post( 'publish' );
$verdict = \PRAutoBlogger_Rerun_Eligibility::check( 'run-1', 99 );
$this->assertFalse( $verdict['ok'] );
$this->assertStringContainsString( 'published', $verdict['reason'] );
}
/**
* check() rejects an actively executing run — "in progress" in the
* CPO sense means unpublished workflow state, not mid-execution.
*/
public function test_check_rejects_actively_executing_run(): void {
$this->wire_run( 'running' );
$this->stub_post( 'draft' );
$verdict = \PRAutoBlogger_Rerun_Eligibility::check( 'run-1', 99 );
$this->assertFalse( $verdict['ok'] );
$this->assertStringContainsString( 'currently executing', $verdict['reason'] );
}
/**
* A terminal run on an unpublished post is eligible: done, failed
* and halted (held) runs can all be re-run.
*/
public function test_check_accepts_terminal_runs_on_unpublished_posts(): void {
foreach ( array( 'done', 'failed', 'halted' ) as $status ) {
$wpdb = $this->create_mock_wpdb();
$wpdb->options = 'wp_options';
$wpdb->method( 'prepare' )->willReturnCallback( static fn( $sql, ...$a ) => (string) $sql );
$wpdb->method( 'get_var' )->willReturnCallback(
static function ( $sql ) {
return ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) ? 'wp_prautoblogger_runs' : null;
}
);
$wpdb->method( 'get_row' )->willReturn( array( 'status' => $status ) );
$GLOBALS['wpdb'] = $wpdb;
\PRAutoBlogger_Run_State::flush_cache();
$this->stub_post( 'draft' );
$verdict = \PRAutoBlogger_Rerun_Eligibility::check( 'run-1', 99 );
$this->assertTrue( $verdict['ok'], "run status {$status} must be eligible: {$verdict['reason']}" );
}
}
/**
* Editable-stage policy: writer chat stages only. review/analysis/
* image stages get the replay affordance refused at the policy layer.
*/
public function test_editable_stage_policy(): void {
foreach ( array( 'outline', 'draft', 'polish' ) as $stage ) {
$this->assertTrue( \PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) );
}
foreach ( array( 'review', 'publish', 'analysis', 'research', 'llm_research', 'image_a', 'image_b', 'curate' ) as $stage ) {
$this->assertFalse( \PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) );
}
$verdict = \PRAutoBlogger_Rerun_Eligibility::check_replay( 'run-1', 99, 'review', 'editor', 'idea:abc' );
$this->assertFalse( $verdict['ok'] );
}
/**
* Downstream/rebuild sets follow the canonical chain — these sets
* drive stale marking and demotion, so order and bounds matter.
*/
public function test_downstream_and_rebuild_sets(): void {
$this->assertSame( array( 'polish', 'review', 'publish' ), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'draft' ) );
$this->assertSame( array( 'publish' ), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'review' ) );
$this->assertSame( array(), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'publish' ) );
$this->assertSame( array(), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'image_a' ) );
$this->assertSame( array( 'review', 'publish' ), \PRAutoBlogger_Rerun_Eligibility::rebuild_set( 'review' ) );
$this->assertSame(
array( 'outline', 'draft', 'polish', 'review', 'publish' ),
\PRAutoBlogger_Rerun_Eligibility::rebuild_set( 'outline' )
);
}
}

View File

@@ -0,0 +1,217 @@
<?php
/**
* Tests for PRAutoBlogger_Rerun_Executor + Job_Support (v0.20.0, M3).
*
* Locks the chained-cron contract (queue() schedules + writes the
* queued transient — nothing executes synchronously), the under-lock
* re-validation (a post published between click and pickup aborts the
* job with ZERO stage mutations), and the terminal-status restore when
* the stage demotion fails after the run was reopened.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class RerunExecutorTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
*/
private $wpdb;
/**
* SQL captured from $wpdb->query().
*
* @var string[]
*/
private array $queries = array();
/**
* Transients captured from set_transient().
*
* @var array<int, array>
*/
private array $transients = array();
protected function setUp(): void {
parent::setUp();
$this->queries = array();
$this->transients = array();
$this->wpdb = $this->create_mock_wpdb();
$this->wpdb->options = 'wp_options';
$GLOBALS['wpdb'] = $this->wpdb;
$this->wpdb->method( 'prepare' )->willReturnCallback(
static function ( $sql, ...$args ) {
if ( 1 === count( $args ) && is_array( $args[0] ) ) {
$args = $args[0];
}
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
}
);
Functions\when( 'set_transient' )->alias(
function ( $key, $value, $ttl = 0 ) {
$this->transients[] = (array) $value;
return true;
}
);
// Monthly budget 0 = no limit (fast false path).
$this->stub_get_option( array( 'prautoblogger_monthly_budget_usd' => '0' ) );
\PRAutoBlogger_Run_State::flush_cache();
\PRAutoBlogger_Run_Stage_State::flush_cache();
\PRAutoBlogger_Stage_Input_Store::flush_cache();
}
protected function tearDown(): void {
\PRAutoBlogger_Run_State::flush_cache();
\PRAutoBlogger_Run_Stage_State::flush_cache();
\PRAutoBlogger_Stage_Input_Store::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/** SHOW TABLES probes succeed for every substrate table. */
private function tables_exist(): void {
$this->wpdb->method( 'get_var' )->willReturnCallback(
static function ( $sql ) {
$sql = (string) $sql;
if ( false !== strpos( $sql, 'SHOW TABLES' ) ) {
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
return 'wp_prautoblogger_stage_inputs';
}
if ( false !== strpos( $sql, 'run_stages' ) ) {
return 'wp_prautoblogger_run_stages';
}
return 'wp_prautoblogger_runs';
}
return null;
}
);
}
/**
* queue() never executes anything: it schedules a single cron event
* with a uniqueness token appended and broadcasts a 'running' status
* transient so board/dossier polling shows the queued state
* (chained-cron semantics — the CPO hard constraint).
*/
public function test_queue_schedules_cron_and_broadcasts_queued_state(): void {
$scheduled = null;
Functions\when( 'wp_schedule_single_event' )->alias(
static function ( $ts, $hook, $args ) use ( &$scheduled ) {
$scheduled = array(
'hook' => $hook,
'args' => $args,
);
return true;
}
);
Functions\when( 'spawn_cron' )->justReturn( true );
Functions\when( 'wp_remote_post' )->justReturn( array() );
Functions\when( 'site_url' )->justReturn( 'https://example.com' );
\PRAutoBlogger_Rerun_Job_Support::queue(
\PRAutoBlogger_Rerun_Executor::REPLAY_ACTION,
array( 'run-1', 99, 'draft', 'writer', 'idea:abc' ),
'Queued: re-running Draft…'
);
$this->assertSame( \PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, $scheduled['hook'] );
$this->assertCount( 6, $scheduled['args'] ); // 5 args + uniqueness token.
$this->assertSame( 'run-1', $scheduled['args'][0] );
$this->assertCount( 1, $this->transients );
$this->assertSame( 'running', $this->transients[0]['status'] );
$this->assertSame( 'Queued: re-running Draft…', $this->transients[0]['stage'] );
}
/**
* Under-lock re-validation: a post published between click and cron
* pickup aborts the replay with an error transient and ZERO
* run_stages mutations (guardrail 5 cannot be raced).
*/
public function test_replay_job_aborts_on_frozen_post_without_mutations(): void {
$this->tables_exist();
$this->wpdb->method( 'query' )->willReturnCallback(
function ( $sql ) {
$this->queries[] = (string) $sql;
return 1; // Lock cleanup + acquire succeed.
}
);
$post = new \WP_Post();
$post->ID = 99;
$post->post_status = 'publish';
Functions\when( 'get_post' )->justReturn( $post );
( new \PRAutoBlogger_Rerun_Executor() )->on_replay_job( 'run-1', 99, 'draft', 'writer', 'idea:abc' );
foreach ( $this->queries as $sql ) {
$this->assertStringNotContainsString( 'run_stages', $sql, 'no stage mutation may happen on a frozen post' );
$this->assertStringNotContainsString( 'run_id = %s AND status IN', $sql, 'the run must not be reopened' );
}
$last = end( $this->transients );
$this->assertSame( 'error', $last['status'] );
$this->assertStringContainsString( 'published', $last['message'] );
}
/**
* When the stage demotion fails after the run was reopened (e.g. the
* row is concurrently 'running'), the previous terminal status is
* restored — the run cannot be left dangling in 'running' for the
* reaper to misread.
*/
public function test_replay_job_restores_terminal_status_when_restart_fails(): void {
$this->tables_exist();
$this->wpdb->method( 'get_row' )->willReturnCallback(
static function ( $sql ) {
$sql = (string) $sql;
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
return array(
'version' => '2',
'request_json' => '{"model":"m","messages":[{"role":"user","content":"edited"}]}',
);
}
return array(
'run_id' => 'run-1',
'status' => 'done',
'ceiling_usd' => '0.50',
);
}
);
$this->wpdb->method( 'query' )->willReturnCallback(
function ( $sql ) {
$sql = (string) $sql;
$this->queries[] = $sql;
if ( false !== strpos( $sql, 'attempt = attempt + 1' ) ) {
return 0; // restart() fails — row not demotable.
}
return 1;
}
);
$post = new \WP_Post();
$post->ID = 99;
$post->post_status = 'draft';
Functions\when( 'get_post' )->justReturn( $post );
( new \PRAutoBlogger_Rerun_Executor() )->on_replay_job( 'run-1', 99, 'draft', 'writer', 'idea:abc' );
$reopened = false;
$restored = false;
foreach ( $this->queries as $sql ) {
if ( false !== strpos( $sql, "ceiling_usd = %f" ) ) {
$reopened = true;
}
// restore_terminal -> mark_status('done') over the reopened 'running' row.
if ( false !== strpos( $sql, 'SET status = %s' ) && false !== strpos( $sql, 'done' )
&& false !== strpos( $sql, "status IN ('pending','running')" ) ) {
$restored = true;
}
}
$this->assertTrue( $reopened, 'run must have been reopened before restart' );
$this->assertTrue( $restored, 'terminal status must be restored after the aborted demotion' );
$last = end( $this->transients );
$this->assertSame( 'error', $last['status'] );
}
}

View File

@@ -0,0 +1,102 @@
<?php
/**
* Tests for PRAutoBlogger_Run_State::reopen() (v0.20.0, M3).
*
* Locks: terminal-only atomic gate, ceiling re-snapshot from the
* CURRENT setting, and — critical for CPO guardrail 4 — that the
* accumulated reserved/settled spend is never reset, so re-runs are
* new spend on the same run ledger.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
class RunStateReopenTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
*/
private $wpdb;
/**
* SQL captured from $wpdb->query().
*
* @var string[]
*/
private array $queries = array();
protected function setUp(): void {
parent::setUp();
$this->queries = array();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS['wpdb'] = $this->wpdb;
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_runs' );
$this->wpdb->method( 'prepare' )->willReturnCallback(
static function ( $sql, ...$args ) {
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
}
);
$this->stub_get_option( array( 'prautoblogger_per_run_cost_ceiling_usd' => '0.75' ) );
\PRAutoBlogger_Run_State::flush_cache();
}
protected function tearDown(): void {
\PRAutoBlogger_Run_State::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* reopen() transitions ONLY terminal runs (atomic conditional UPDATE)
* and re-snapshots the ceiling from the current setting.
*/
public function test_reopen_gates_on_terminal_status_and_resnapshots_ceiling(): void {
$this->wpdb->method( 'query' )->willReturnCallback(
function ( $sql ) {
$this->queries[] = (string) $sql;
return 1;
}
);
$this->assertTrue( \PRAutoBlogger_Run_State::reopen( 'run-1' ) );
$sql = $this->queries[0];
$this->assertStringContainsString( "SET status = 'running'", $sql );
$this->assertStringContainsString( "status IN ('done','failed','halted')", $sql );
$this->assertStringContainsString( 'ceiling_usd = %f', $sql );
$this->assertStringContainsString( '0.75', $sql ); // Current setting value.
$this->assertStringContainsString( 'finished_at = NULL', $sql );
// Guardrail 4: accumulated spend is NEVER reset by a reopen.
$this->assertStringNotContainsString( 'reserved_usd', $sql );
$this->assertStringNotContainsString( 'settled_usd', $sql );
$this->assertStringNotContainsString( 'overage_usd', $sql );
}
/**
* No row matched (run is pending/running or absent) -> false, so the
* executor aborts instead of replaying against a closed gate.
*/
public function test_reopen_returns_false_when_not_terminal(): void {
$this->wpdb->method( 'query' )->willReturn( 0 );
$this->assertFalse( \PRAutoBlogger_Run_State::reopen( 'run-1' ) );
}
/**
* Self-healing: missing table / empty run id -> false, no query.
*/
public function test_reopen_noop_paths(): void {
$this->wpdb->method( 'query' )->willReturnCallback(
function ( $sql ) {
$this->queries[] = (string) $sql;
return 1;
}
);
$this->assertFalse( \PRAutoBlogger_Run_State::reopen( '' ) );
$this->assertCount( 0, $this->queries );
}
}

View File

@@ -0,0 +1,157 @@
<?php
/**
* Tests for PRAutoBlogger_Stage_Replay (v0.20.0, M3).
*
* Locks the body -> options inverse transform (the replay must
* round-trip through Request_Builder::build_body() to the SAME wire
* shape, including the v0.18.1 reasoning headroom) and the decode
* validation that guards the replay path against non-chat payloads.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
class StageReplayTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
// Builder reads the global reasoning setting; pin it OFF here and
// flip it in the drift test below.
$this->stub_get_option( array( 'prautoblogger_reasoning_enabled' => '0' ) );
}
/**
* Round-trip: a body WITHOUT reasoning replayed through build_body()
* reproduces the original wire shape byte-for-byte.
*/
public function test_round_trip_without_reasoning(): void {
$original = array(
'model' => 'google/gemini-2.5-flash-lite',
'messages' => array(
array( 'role' => 'system', 'content' => 'sys' ),
array( 'role' => 'user', 'content' => 'edited prompt' ),
),
'temperature' => 0.7,
'max_tokens' => 4000,
);
$options = \PRAutoBlogger_Stage_Replay::options_from_body( $original, 'draft' );
$rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body(
$original['messages'],
$original['model'],
$options
);
// The explicit reasoning-off pin is the only permitted delta
// (same shape the empty-completion retry sends upstream).
$this->assertSame( array( 'enabled' => false ), $rebuilt['reasoning'] );
unset( $rebuilt['reasoning'] );
$this->assertSame( $original, array_merge( array( 'model' => $original['model'], 'messages' => $original['messages'] ), $rebuilt ) );
$this->assertSame( 4000, $rebuilt['max_tokens'] );
}
/**
* Round-trip WITH reasoning: the stored body's max_tokens carries the
* builder's +cap headroom; the inverse subtracts it so the rebuilt
* body is identical (headroom applied once, never compounded).
*/
public function test_round_trip_with_reasoning_headroom(): void {
$original = array(
'model' => 'deepseek/deepseek-v4-flash',
'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ),
'max_tokens' => 6048, // Caller asked 4000; builder added the 2048 cap.
'reasoning' => array(
'enabled' => true,
'max_tokens' => 2048,
),
);
$options = \PRAutoBlogger_Stage_Replay::options_from_body( $original, 'draft' );
$this->assertSame( 4000, $options['max_tokens'] );
$rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body(
$original['messages'],
$original['model'],
$options
);
$this->assertSame( 6048, $rebuilt['max_tokens'] );
$this->assertSame( $original['reasoning'], $rebuilt['reasoning'] );
}
/**
* Global-setting drift cannot reshape a replay: even with reasoning
* globally ENABLED, a body stored without reasoning replays with the
* explicit off-pin (build_body honours the per-call override).
*/
public function test_global_reasoning_drift_cannot_leak_into_replay(): void {
$this->stub_get_option(
array(
'prautoblogger_reasoning_enabled' => '1',
'prautoblogger_reasoning_effort' => 'high',
)
);
$body = array(
'model' => 'google/gemini-2.5-flash-lite',
'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ),
'max_tokens' => 1000,
);
$options = \PRAutoBlogger_Stage_Replay::options_from_body( $body, 'polish' );
$rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body( $body['messages'], $body['model'], $options );
$this->assertSame( array( 'enabled' => false ), $rebuilt['reasoning'] );
$this->assertSame( 1000, $rebuilt['max_tokens'] ); // No headroom added.
}
/**
* Caller metadata (stage/prompt_key) rides options for the guard's
* audit rows but never reaches the wire body.
*/
public function test_options_carry_stage_metadata_not_sent_upstream(): void {
$body = array(
'model' => 'm',
'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ),
);
$options = \PRAutoBlogger_Stage_Replay::options_from_body( $body, 'draft' );
$this->assertSame( 'draft', $options['stage'] );
$this->assertSame( 'content.draft', $options['prompt_key'] );
$rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body( $body['messages'], $body['model'], $options );
$this->assertArrayNotHasKey( 'stage', $rebuilt );
$this->assertArrayNotHasKey( 'prompt_key', $rebuilt );
}
/**
* decode_body() accepts only well-formed chat bodies — model string +
* non-empty role/content message list. Everything else returns null
* (the executor aborts cleanly instead of replaying garbage).
*/
public function test_decode_body_validation(): void {
$valid = wp_json_encode(
array(
'model' => 'm',
'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ),
)
);
$this->assertIsArray( \PRAutoBlogger_Stage_Replay::decode_body( (string) $valid ) );
$invalid = array(
'not json at all',
'"just a string"',
'{"messages":[{"role":"user","content":"p"}]}', // No model.
'{"model":"m"}', // No messages.
'{"model":"m","messages":[]}', // Empty messages.
'{"model":"m","messages":["plain string"]}', // Malformed message.
'{"model":"m","messages":[{"role":"user"}]}', // No content.
'{"model":"m","messages":[{"role":1,"content":"p"}]}', // Non-string role.
);
foreach ( $invalid as $payload ) {
$this->assertNull( \PRAutoBlogger_Stage_Replay::decode_body( $payload ), "must reject: {$payload}" );
}
}
}