feat(B1): persist request_json on every chat LLM call via process-scoped recorder

The OpenRouter provider stashes the outgoing request BODY (never headers --
Authorization is built separately and build_body() copies only whitelisted
option keys) in the new PRAutoBlogger_Request_Recorder before dispatch;
Cost_Tracker::log_api_call() consumes it (consume-once) into
generation_log.request_json. All seven chat call sites covered with zero
call-site changes; error/timeout rows carry the request too. Retention
rides the existing prautoblogger_request_json_retention_days prune.

Unblocks M3 edit+rerun (stage inputs now exist) and makes the M2 dossier
raw-input trace real (QA seq 20 F1).

Agent-Session: engineer-prab-m3-20260612
This commit is contained in:
peptiderepo
2026-06-12 12:59:49 +00:00
parent 3b6a302f95
commit 1b31ef6396
5 changed files with 385 additions and 1 deletions

View File

@@ -0,0 +1,181 @@
<?php
/**
* Tests for the v0.20.0 (B1) request_json persistence in
* PRAutoBlogger_Cost_Tracker::log_api_call().
*
* Every chat call's request body must land on its generation_log row
* (stage inputs exist for edit + re-run; the dossier raw-input trace is
* no longer hollow — QA M2 F1), with consume-once isolation between rows.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
class CostTrackerRequestJsonTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb instance.
*/
private $wpdb;
/**
* Rows captured from $wpdb->insert().
*
* @var array<int, array<string, mixed>>
*/
private array $inserted = array();
protected function setUp(): void {
parent::setUp();
$this->inserted = array();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS['wpdb'] = $this->wpdb;
$this->wpdb->insert_id = 7;
$this->wpdb->method( 'insert' )->willReturnCallback(
function ( $table, $row ) {
// Capture generation_log rows only — the Logger also
// inserts (event_log) when e.g. Pricing warns on an
// unknown model; those rows are not under test.
if ( false !== strpos( (string) $table, 'generation_log' ) ) {
$this->inserted[] = (array) $row;
}
return 1;
}
);
$this->stub_get_option( array() );
\PRAutoBlogger_Request_Recorder::clear();
}
protected function tearDown(): void {
\PRAutoBlogger_Request_Recorder::clear();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* log_api_call() persists the recorder stash into request_json.
*/
public function test_log_api_call_persists_recorded_request_json(): void {
\PRAutoBlogger_Request_Recorder::record(
array(
'model' => 'google/gemini-2.5-flash-lite',
'messages' => array( array( 'role' => 'user', 'content' => 'draft prompt' ) ),
)
);
( new \PRAutoBlogger_Cost_Tracker() )->log_api_call(
null,
'draft',
'OpenRouter',
'google/gemini-2.5-flash-lite',
100,
500
);
$this->assertCount( 1, $this->inserted );
$row = $this->inserted[0];
$this->assertArrayHasKey( 'request_json', $row );
$decoded = json_decode( (string) $row['request_json'], true );
$this->assertSame( 'draft prompt', $decoded['messages'][0]['content'] );
}
/**
* Consume-once isolation: a second log row written with no new send
* in between must get NULL, never the previous call's body.
*/
public function test_second_log_row_without_new_record_gets_null(): void {
\PRAutoBlogger_Request_Recorder::record( array( 'model' => 'm1' ) );
$tracker = new \PRAutoBlogger_Cost_Tracker();
$tracker->log_api_call( null, 'draft', 'OpenRouter', 'm1', 10, 10 );
$tracker->log_api_call( null, 'polish', 'OpenRouter', 'm1', 10, 10 );
$this->assertCount( 2, $this->inserted );
$this->assertNotNull( $this->inserted[0]['request_json'] );
$this->assertNull( $this->inserted[1]['request_json'] );
}
/**
* Historical behavior preserved: nothing recorded -> request_json NULL.
*/
public function test_log_api_call_without_stash_writes_null(): void {
( new \PRAutoBlogger_Cost_Tracker() )->log_api_call(
null,
'analysis',
'OpenRouter',
'google/gemini-2.5-flash-lite',
10,
10
);
$this->assertCount( 1, $this->inserted );
$this->assertNull( $this->inserted[0]['request_json'] );
}
/**
* The persisted payload can never contain credential material — the
* provider records only build_body() output (headers are separate).
* Belt-and-braces audit on the exact column value.
*/
public function test_persisted_request_json_contains_no_auth_header(): void {
$this->stub_get_option( array() );
$builder = new \PRAutoBlogger_OpenRouter_Request_Builder();
\PRAutoBlogger_Request_Recorder::record(
$builder->build_body(
array( array( 'role' => 'user', 'content' => 'x' ) ),
'google/gemini-2.5-flash-lite',
array( 'max_tokens' => 100 )
)
);
( new \PRAutoBlogger_Cost_Tracker() )->log_api_call(
null,
'draft',
'OpenRouter',
'google/gemini-2.5-flash-lite',
10,
10
);
$json = (string) $this->inserted[0]['request_json'];
$this->assertStringNotContainsString( 'Authorization', $json );
$this->assertStringNotContainsString( 'Bearer', $json );
$this->assertStringNotContainsString( 'sk-or-', $json );
}
/**
* The half-migrated-schema legacy retry (v0.18.0 pattern) keeps
* request_json — the column predates the audit columns (v1.1.0
* schema) and exists on every site.
*/
public function test_legacy_schema_retry_keeps_request_json(): void {
$this->inserted = array();
$wpdb = $this->create_mock_wpdb();
$wpdb->insert_id = 7;
$calls = 0;
$wpdb->method( 'insert' )->willReturnCallback(
function ( $table, $row ) use ( &$calls ) {
if ( false === strpos( (string) $table, 'generation_log' ) ) {
return 1; // Logger/event_log noise — not under test.
}
++$calls;
$this->inserted[] = (array) $row;
return 1 === $calls ? false : 1; // First insert fails (missing v0.18.0 columns).
}
);
$GLOBALS['wpdb'] = $wpdb;
\PRAutoBlogger_Request_Recorder::record( array( 'model' => 'm1' ) );
( new \PRAutoBlogger_Cost_Tracker() )->log_api_call( null, 'draft', 'OpenRouter', 'm1', 10, 10 );
$this->assertCount( 2, $this->inserted );
$retry_row = $this->inserted[1];
$this->assertArrayNotHasKey( 'agent_role', $retry_row );
$this->assertArrayNotHasKey( 'prompt_version', $retry_row );
$this->assertArrayHasKey( 'request_json', $retry_row );
$this->assertNotNull( $retry_row['request_json'] );
}
}

View File

@@ -0,0 +1,120 @@
<?php
/**
* Tests for PRAutoBlogger_Request_Recorder (v0.20.0 / B1).
*
* Locks the consume-once + overwrite-on-record semantics that make it
* impossible for a stale request body to attach to an unrelated log row,
* and the no-Authorization guarantee of the record path.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
class RequestRecorderTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
\PRAutoBlogger_Request_Recorder::clear();
}
protected function tearDown(): void {
\PRAutoBlogger_Request_Recorder::clear();
parent::tearDown();
}
/**
* consume() returns the recorded body exactly once, then null.
*/
public function test_consume_returns_recorded_body_exactly_once(): void {
\PRAutoBlogger_Request_Recorder::record(
array(
'model' => 'google/gemini-2.5-flash-lite',
'messages' => array( array( 'role' => 'user', 'content' => 'hi' ) ),
)
);
$first = \PRAutoBlogger_Request_Recorder::consume();
$this->assertIsString( $first );
$decoded = json_decode( $first, true );
$this->assertSame( 'google/gemini-2.5-flash-lite', $decoded['model'] );
$this->assertSame( 'hi', $decoded['messages'][0]['content'] );
// Consume-once: the slot is cleared by the first read.
$this->assertNull( \PRAutoBlogger_Request_Recorder::consume() );
}
/**
* record() overwrites the previous stash — a stale body from an
* unlogged earlier call can never survive into the next dispatch.
*/
public function test_record_overwrites_previous_stash(): void {
\PRAutoBlogger_Request_Recorder::record( array( 'model' => 'first/model' ) );
\PRAutoBlogger_Request_Recorder::record( array( 'model' => 'second/model' ) );
$decoded = json_decode( (string) \PRAutoBlogger_Request_Recorder::consume(), true );
$this->assertSame( 'second/model', $decoded['model'] );
}
/**
* consume() with nothing recorded returns null (historical rows /
* non-chat log writes keep request_json NULL).
*/
public function test_consume_without_record_returns_null(): void {
$this->assertNull( \PRAutoBlogger_Request_Recorder::consume() );
}
/**
* clear() empties the slot.
*/
public function test_clear_empties_slot(): void {
\PRAutoBlogger_Request_Recorder::record( array( 'model' => 'm' ) );
\PRAutoBlogger_Request_Recorder::clear();
$this->assertNull( \PRAutoBlogger_Request_Recorder::consume() );
}
/**
* The full record path for a body built by the real Request_Builder
* from poisoned options can never contain credential material:
* build_body() copies only whitelisted keys and headers are built
* separately, so the recorder's JSON has no Authorization shape.
*/
public function test_recorded_builder_body_never_contains_auth_material(): void {
$this->stub_get_option( array() );
$builder = new \PRAutoBlogger_OpenRouter_Request_Builder();
$body = $builder->build_body(
array( array( 'role' => 'user', 'content' => 'write about BPC-157' ) ),
'google/gemini-2.5-flash-lite',
array(
'temperature' => 0.7,
'max_tokens' => 4000,
// Hostile/accidental injection attempts — must all be dropped.
'Authorization' => 'Bearer sk-or-secret-key-123',
'headers' => array( 'Authorization' => 'Bearer sk-or-secret-key-123' ),
'api_key' => 'sk-or-secret-key-123',
// Caller metadata — never sent upstream, never recorded.
'stage' => 'draft',
'prompt_key' => 'content.draft',
'empty_retry' => true,
)
);
\PRAutoBlogger_Request_Recorder::record( $body );
$json = (string) \PRAutoBlogger_Request_Recorder::consume();
$this->assertStringNotContainsString( 'Authorization', $json );
$this->assertStringNotContainsString( 'Bearer', $json );
$this->assertStringNotContainsString( 'sk-or-', $json );
$this->assertStringNotContainsString( 'api_key', $json );
$this->assertStringNotContainsString( 'empty_retry', $json );
$this->assertStringNotContainsString( 'prompt_key', $json );
$decoded = json_decode( $json, true );
$this->assertSame(
array( 'model', 'messages', 'temperature', 'max_tokens' ),
array_keys( $decoded )
);
}
}