Files
prautoblogger/tests/unit/Core/CostTrackerRequestJsonTest.php
peptiderepo ac2f20de7e feat: M3 edit + single-step re-run — input versioning, human-modified audit, staged re-run (v0.20.0) (#161)
QA: REJECT@6ed639d (CTO rebase header P1) -> APPROVE@6727e07 (qa-reviews/prautoblogger/2026-06-12-6727e07.md)
CPO guardrails 1-5 verified hard-AC. CEO conditional greenlight 2026-06-12 in chat (QA-APPROVE condition met).

Agent-Session: cto-m3-merge-20260612
2026-06-12 21:46:49 +07:00

182 lines
5.5 KiB
PHP

<?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'] );
}
}