test: split PublisherTest and OpenRouterProviderTest under the 300-line rule

QA F2 (P2): this PR pushed PublisherTest to 333 lines and
OpenRouterProviderTest to 318. Both splits are mechanical (test groups
moved verbatim to a sibling file; setUp duplicated; no test logic or
assertion changes):

- OpenRouterProviderTest (318 -> 169): wire_full_provider() + the two
  v0.18.1 retry-path integration tests move to
  OpenRouterProviderRetryTest.php (223). The remaining file is
  byte-identical to its pre-PR state on main.
- PublisherTest (333 -> 202): the five post-creation side-effect tests
  (taxonomy, tags, run_id log linking, action/filter hooks — incl. the
  two pre-existing skipped ones, moved as-is) move to
  PublisherSideEffectsTest.php (183). Guard tests stay with the
  create_post() funnel tests.

Suite count unchanged (302 tests); all four files pass locally.

Agent-Session: prab-empty-draft-guard-20260611
This commit is contained in:
peptiderepo
2026-06-11 13:50:26 +00:00
committed by peptiderepo
parent b7ffce8d88
commit 27ef37fe15
4 changed files with 406 additions and 280 deletions

View File

@@ -0,0 +1,183 @@
<?php
/**
* Side-effect tests for PRAutoBlogger_Publisher::publish().
*
* Validates the post-creation side effects: taxonomy assignment
* (category from article_type, tags from keywords), generation-log
* linking by run_id, and the public action/filter hooks. Split from
* PublisherTest.php to keep both files under the 300-line rule;
* post-creation/status/metadata/guard tests stay in PublisherTest.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
class PublisherSideEffectsTest extends BaseTestCase {
private \PRAutoBlogger_Article_Idea $idea;
private \PRAutoBlogger_Editorial_Review $review;
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() );
// Common stubs needed by Publisher.
$this->stub_get_option( [
'prautoblogger_writing_pipeline' => 'multi_step',
'prautoblogger_writing_model' => 'anthropic/claude-sonnet-4',
'prautoblogger_default_author' => 1,
] );
Functions\when( 'sanitize_text_field' )->returnArg();
Functions\when( 'wp_kses_post' )->returnArg();
// v0.18.1 empty-content guard strips tags before the emptiness check.
Functions\when( 'wp_strip_all_tags' )->alias( function ( $text ) {
return trim( strip_tags( (string) $text ) );
} );
Functions\when( 'is_wp_error' )->justReturn( false );
Functions\when( 'apply_filters' )->returnArg( 2 );
Functions\when( 'absint' )->alias( function ( $val ) { return abs( (int) $val ); } );
Functions\when( 'get_users' )->justReturn( [ 1 ] );
Functions\when( 'get_term_by' )->justReturn( false );
Functions\when( 'wp_insert_term' )->justReturn( [ 'term_id' => 5 ] );
Functions\when( 'wp_set_post_categories' )->justReturn( true );
Functions\when( 'wp_set_post_tags' )->justReturn( true );
}
/**
* Test that taxonomy terms are assigned based on article_type from the idea.
*/
public function test_publish_assigns_category_from_article_type(): void {
Functions\when( 'wp_insert_post' )->justReturn( 46 );
// get_term_by returns false (category doesn't exist), so wp_insert_term is called.
$captured_category = null;
Functions\when( 'wp_insert_term' )->alias( function ( $name, $taxonomy ) use ( &$captured_category ) {
$captured_category = $name;
return [ 'term_id' => 10 ];
} );
$captured_category_ids = null;
Functions\when( 'wp_set_post_categories' )->alias( function ( $post_id, $cat_ids ) use ( &$captured_category_ids ) {
$captured_category_ids = $cat_ids;
return true;
} );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
// Article type is 'guide' → category should be 'Guides'.
$this->assertSame( 'Guides', $captured_category );
$this->assertSame( [ 10 ], $captured_category_ids );
}
/**
* Test that target keywords are set as post tags.
*/
public function test_publish_sets_tags_from_keywords(): void {
Functions\when( 'wp_insert_post' )->justReturn( 47 );
$captured_tags = null;
Functions\when( 'wp_set_post_tags' )->alias( function ( $post_id, $tags, $append ) use ( &$captured_tags ) {
$captured_tags = $tags;
return true;
} );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
$this->assertSame( [ 'test', 'keyword', 'example' ], $captured_tags );
}
/**
* Test that generation log entries are linked via run_id.
*/
public function test_publish_links_generation_logs_by_run_id(): void {
Functions\when( 'wp_insert_post' )->justReturn( 48 );
// Create an idea fixture without source_ids to avoid triggering SourceCollector
// queries that would overwrite captured_query in the mock.
$fixture = $this->get_article_idea_fixture();
$fixture['source_ids'] = [];
$idea = new \PRAutoBlogger_Article_Idea( $fixture );
$captured_query = null;
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'prepare' )->willReturnCallback( function ( $sql, ...$args ) use ( &$captured_query ) {
$captured_query = $sql;
return $sql;
} );
$wpdb->method( 'query' )->willReturn( 1 );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $idea, $this->review, 'run_link_test' );
// Should use run_id-based query, not timestamp-based.
$this->assertStringContainsString( 'run_id', $captured_query );
$this->assertStringNotContainsString( 'created_at', $captured_query );
}
/**
* Test that the prautoblogger_post_created action is fired after publishing.
*/
public function test_publish_fires_post_created_action(): void {
// TODO(peptiderepo): this test is latently broken — Mockery expects do_action_prautoblogger_post_created
// to be called, but Publisher::publish() is not firing it in the current mock setup. Track in a follow-up
// PR; skipping for now so CI parity can land. The bug is likely a mis-mocked do_action or a refactor
// that removed the action fire.
$this->markTestSkipped( 'Latent bug — see TODO; tracked for follow-up PR.' );
Functions\when( 'wp_insert_post' )->justReturn( 49 );
Functions\when( 'do_action' )->alias( function () {} );
Actions\expectDone( 'prautoblogger_post_created' )
->once()
->with( 49, 'publish', $this->idea, $this->review );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
}
/**
* Test that the prautoblogger_filter_post_data filter is applied.
*/
public function test_publish_applies_post_data_filter(): void {
// TODO(peptiderepo): latently broken alongside test_publish_fires_post_created_action.
// apply_filters('prautoblogger_post_data', ...) expected but not called. Follow-up PR.
$this->markTestSkipped( 'Latent bug — see TODO; tracked for follow-up PR.' );
Functions\when( 'wp_insert_post' )->justReturn( 50 );
Filters\expectApplied( 'prautoblogger_filter_post_data' )->once();
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
}
}

View File

@@ -13,8 +13,6 @@ namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
class PublisherTest extends BaseTestCase {
@@ -171,135 +169,6 @@ class PublisherTest extends BaseTestCase {
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
}
/**
* Test that taxonomy terms are assigned based on article_type from the idea.
*/
public function test_publish_assigns_category_from_article_type(): void {
Functions\when( 'wp_insert_post' )->justReturn( 46 );
// get_term_by returns false (category doesn't exist), so wp_insert_term is called.
$captured_category = null;
Functions\when( 'wp_insert_term' )->alias( function ( $name, $taxonomy ) use ( &$captured_category ) {
$captured_category = $name;
return [ 'term_id' => 10 ];
} );
$captured_category_ids = null;
Functions\when( 'wp_set_post_categories' )->alias( function ( $post_id, $cat_ids ) use ( &$captured_category_ids ) {
$captured_category_ids = $cat_ids;
return true;
} );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
// Article type is 'guide' → category should be 'Guides'.
$this->assertSame( 'Guides', $captured_category );
$this->assertSame( [ 10 ], $captured_category_ids );
}
/**
* Test that target keywords are set as post tags.
*/
public function test_publish_sets_tags_from_keywords(): void {
Functions\when( 'wp_insert_post' )->justReturn( 47 );
$captured_tags = null;
Functions\when( 'wp_set_post_tags' )->alias( function ( $post_id, $tags, $append ) use ( &$captured_tags ) {
$captured_tags = $tags;
return true;
} );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
$this->assertSame( [ 'test', 'keyword', 'example' ], $captured_tags );
}
/**
* Test that generation log entries are linked via run_id.
*/
public function test_publish_links_generation_logs_by_run_id(): void {
Functions\when( 'wp_insert_post' )->justReturn( 48 );
// Create an idea fixture without source_ids to avoid triggering SourceCollector
// queries that would overwrite captured_query in the mock.
$fixture = $this->get_article_idea_fixture();
$fixture['source_ids'] = [];
$idea = new \PRAutoBlogger_Article_Idea( $fixture );
$captured_query = null;
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'prepare' )->willReturnCallback( function ( $sql, ...$args ) use ( &$captured_query ) {
$captured_query = $sql;
return $sql;
} );
$wpdb->method( 'query' )->willReturn( 1 );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $idea, $this->review, 'run_link_test' );
// Should use run_id-based query, not timestamp-based.
$this->assertStringContainsString( 'run_id', $captured_query );
$this->assertStringNotContainsString( 'created_at', $captured_query );
}
/**
* Test that the prautoblogger_post_created action is fired after publishing.
*/
public function test_publish_fires_post_created_action(): void {
// TODO(peptiderepo): this test is latently broken — Mockery expects do_action_prautoblogger_post_created
// to be called, but Publisher::publish() is not firing it in the current mock setup. Track in a follow-up
// PR; skipping for now so CI parity can land. The bug is likely a mis-mocked do_action or a refactor
// that removed the action fire.
$this->markTestSkipped( 'Latent bug — see TODO; tracked for follow-up PR.' );
Functions\when( 'wp_insert_post' )->justReturn( 49 );
Functions\when( 'do_action' )->alias( function () {} );
Actions\expectDone( 'prautoblogger_post_created' )
->once()
->with( 49, 'publish', $this->idea, $this->review );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
}
/**
* Test that the prautoblogger_filter_post_data filter is applied.
*/
public function test_publish_applies_post_data_filter(): void {
// TODO(peptiderepo): latently broken alongside test_publish_fires_post_created_action.
// apply_filters('prautoblogger_post_data', ...) expected but not called. Follow-up PR.
$this->markTestSkipped( 'Latent bug — see TODO; tracked for follow-up PR.' );
Functions\when( 'wp_insert_post' )->justReturn( 50 );
Filters\expectApplied( 'prautoblogger_filter_post_data' )->once();
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
}
/**
* v0.18.1 belt-and-braces: save_as_draft() with empty content throws
* and never reaches wp_insert_post — no empty draft post can be

View File

@@ -0,0 +1,223 @@
<?php
/**
* Retry-path integration tests for PRAutoBlogger_OpenRouter_Provider.
*
* Wires the full send_chat_completion() path (decryptable API key,
* scripted HTTP responses, wpdb mock for failure bookkeeping) to verify
* the v0.18.1 empty-completion guard end to end: the incident-shape
* single retry with reasoning disabled, and the reasoning token cap
* shaping the outgoing request. Split from OpenRouterProviderTest.php
* to keep both files under the 300-line rule. All HTTP calls are
* mocked — no real API calls.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class OpenRouterProviderRetryTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
// Stub get_option — provider calls get_option('prautoblogger_openrouter_api_key', '')
// for API key retrieval via private get_api_key() method.
// Return empty string so decryption is skipped (no key = empty return).
// Also include prautoblogger_log_level for Logger singleton.
$this->stub_get_option( [
'prautoblogger_openrouter_api_key' => '',
'prautoblogger_log_level' => 'info',
] );
// Stub wp_salt — called by PRAutoBlogger_Encryption during API key decryption.
Functions\when( 'wp_salt' )->justReturn( 'test_salt_key_for_unit_tests' );
// Stub get_transient/set_transient — used by OpenRouter_Pricing (called internally).
Functions\when( 'get_transient' )->alias(
function ( string $key ) {
if ( 'prautoblogger_openrouter_models' === $key ) {
return [
[
'id' => 'model/test',
'name' => 'Test Model',
'context_length' => 4096,
'pricing' => [ 'prompt' => 1.00, 'completion' => 2.00 ],
],
];
}
return false;
}
);
Functions\when( 'set_transient' )->justReturn( true );
// Stub URL parsing.
Functions\when( 'wp_parse_url' )->alias( 'parse_url' );
// Stub HTTP functions for API calls.
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => '{"choices":[{"message":{"content":"test"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}',
'response' => [ 'code' => 200 ],
] );
Functions\when( 'wp_remote_get' )->justReturn( [
'body' => '{}',
'response' => [ 'code' => 200 ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn(
'{"choices":[{"message":{"content":"test"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}'
);
Functions\when( 'is_wp_error' )->justReturn( false );
}
/**
* Wire the provider for a full send_chat_completion run: decryptable
* API key, reasoning settings, HTTP stubs that replay scripted
* responses, and a wpdb mock for the guard's failure bookkeeping.
*
* @param array $option_overrides Extra get_option values.
* @param array $responses Scripted JSON-decodable response bodies.
* @param array $requests (by ref) captures decoded request bodies.
*/
private function wire_full_provider( array $option_overrides, array $responses, array &$requests ): void {
\PRAutoBlogger_Run_Context::clear();
$encrypted_key = \PRAutoBlogger_Encryption::encrypt( 'sk-or-v1-unit-test-key-0000' );
$this->stub_get_option( array_merge( [
'prautoblogger_openrouter_api_key' => $encrypted_key,
'prautoblogger_log_level' => 'error',
], $option_overrides ) );
Functions\when( 'absint' )->alias( function ( $val ) {
return abs( (int) $val );
} );
Functions\when( 'wp_remote_post' )->alias(
function ( $url, $args ) use ( &$requests, $responses ) {
$requests[] = json_decode( $args['body'], true );
$body = $responses[ count( $requests ) - 1 ];
return [
'body' => $body,
'response' => [ 'code' => 200 ],
];
}
);
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
function ( $response ) {
return $response['response']['code'];
}
);
Functions\when( 'wp_remote_retrieve_body' )->alias(
function ( $response ) {
return $response['body'];
}
);
// The guard books failed attempts through Cost_Tracker + Logger.
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'insert' )->willReturn( 1 );
$wpdb->insert_id = 11;
$GLOBALS['wpdb'] = $wpdb;
}
/**
* v0.18.1 incident shape, end to end: reasoning enabled (uncapped
* effort mode), the first response burns the whole completion budget
* on thinking and returns empty content with finish_reason=length —
* the provider retries exactly once with reasoning disabled and
* returns the recovered completion.
*/
public function test_empty_length_completion_retries_once_with_reasoning_disabled(): void {
$requests = [];
$empty_response = json_encode( [
'model' => 'deepseek/deepseek-v4-flash',
'choices' => [ [ 'message' => [ 'content' => '', 'reasoning' => 'endless thinking…' ], 'finish_reason' => 'length' ] ],
'usage' => [ 'prompt_tokens' => 1200, 'completion_tokens' => 4000, 'reasoning_tokens' => 4000, 'total_tokens' => 5200 ],
] );
$good_response = json_encode( [
'model' => 'deepseek/deepseek-v4-flash',
'choices' => [ [ 'message' => [ 'content' => 'Recovered article.' ], 'finish_reason' => 'stop' ] ],
'usage' => [ 'prompt_tokens' => 1200, 'completion_tokens' => 900, 'total_tokens' => 2100 ],
] );
$this->wire_full_provider(
[
'prautoblogger_reasoning_enabled' => '1',
'prautoblogger_reasoning_effort' => 'xhigh',
'prautoblogger_reasoning_max_tokens' => 0,
],
[ $empty_response, $good_response ],
$requests
);
try {
$provider = new \PRAutoBlogger_OpenRouter_Provider();
$result = $provider->send_chat_completion(
[ [ 'role' => 'user', 'content' => 'Write the article.' ] ],
'deepseek/deepseek-v4-flash',
[ 'temperature' => 0.7, 'max_tokens' => 4000, 'stage' => 'draft' ]
);
$this->assertSame( 'Recovered article.', $result['content'] );
$this->assertCount( 2, $requests );
// First attempt: global reasoning, uncapped effort mode.
$this->assertSame( [ 'enabled' => true, 'effort' => 'xhigh' ], $requests[0]['reasoning'] );
$this->assertSame( 4000, $requests[0]['max_tokens'] );
// Retry: reasoning disabled per call.
$this->assertSame( [ 'enabled' => false ], $requests[1]['reasoning'] );
// Caller metadata never reaches the HTTP body.
$this->assertArrayNotHasKey( 'stage', $requests[0] );
$this->assertArrayNotHasKey( 'empty_retry', $requests[1] );
} finally {
unset( $GLOBALS['wpdb'] );
\PRAutoBlogger_Run_Context::clear();
}
}
/**
* v0.18.1 reasoning budget: with the token cap active (default), the
* outgoing request caps thinking via reasoning.max_tokens and raises
* max_tokens by the cap — and a healthy completion needs no retry.
*/
public function test_reasoning_cap_shapes_the_outgoing_request(): void {
$requests = [];
$good_response = json_encode( [
'model' => 'deepseek/deepseek-v4-flash',
'choices' => [ [ 'message' => [ 'content' => 'Article text.' ], 'finish_reason' => 'stop' ] ],
'usage' => [ 'prompt_tokens' => 1200, 'completion_tokens' => 900, 'total_tokens' => 2100 ],
] );
$this->wire_full_provider(
[
'prautoblogger_reasoning_enabled' => '1',
'prautoblogger_reasoning_effort' => 'xhigh',
'prautoblogger_reasoning_max_tokens' => 2048,
],
[ $good_response ],
$requests
);
try {
$provider = new \PRAutoBlogger_OpenRouter_Provider();
$result = $provider->send_chat_completion(
[ [ 'role' => 'user', 'content' => 'Write the article.' ] ],
'deepseek/deepseek-v4-flash',
[ 'temperature' => 0.7, 'max_tokens' => 4000, 'stage' => 'draft' ]
);
$this->assertSame( 'Article text.', $result['content'] );
$this->assertCount( 1, $requests );
$this->assertSame( 2048, $requests[0]['reasoning']['max_tokens'] );
$this->assertArrayNotHasKey( 'effort', $requests[0]['reasoning'] );
$this->assertSame( 6048, $requests[0]['max_tokens'] );
} finally {
unset( $GLOBALS['wpdb'] );
\PRAutoBlogger_Run_Context::clear();
}
}
}

View File

@@ -166,153 +166,4 @@ class OpenRouterProviderTest extends BaseTestCase {
$this->expectException( \RuntimeException::class );
$provider->send_chat_completion( $messages, 'model/test', $options );
}
/**
* Wire the provider for a full send_chat_completion run: decryptable
* API key, reasoning settings, HTTP stubs that replay scripted
* responses, and a wpdb mock for the guard's failure bookkeeping.
*
* @param array $option_overrides Extra get_option values.
* @param array $responses Scripted JSON-decodable response bodies.
* @param array $requests (by ref) captures decoded request bodies.
*/
private function wire_full_provider( array $option_overrides, array $responses, array &$requests ): void {
\PRAutoBlogger_Run_Context::clear();
$encrypted_key = \PRAutoBlogger_Encryption::encrypt( 'sk-or-v1-unit-test-key-0000' );
$this->stub_get_option( array_merge( [
'prautoblogger_openrouter_api_key' => $encrypted_key,
'prautoblogger_log_level' => 'error',
], $option_overrides ) );
Functions\when( 'absint' )->alias( function ( $val ) {
return abs( (int) $val );
} );
Functions\when( 'wp_remote_post' )->alias(
function ( $url, $args ) use ( &$requests, $responses ) {
$requests[] = json_decode( $args['body'], true );
$body = $responses[ count( $requests ) - 1 ];
return [
'body' => $body,
'response' => [ 'code' => 200 ],
];
}
);
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
function ( $response ) {
return $response['response']['code'];
}
);
Functions\when( 'wp_remote_retrieve_body' )->alias(
function ( $response ) {
return $response['body'];
}
);
// The guard books failed attempts through Cost_Tracker + Logger.
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'insert' )->willReturn( 1 );
$wpdb->insert_id = 11;
$GLOBALS['wpdb'] = $wpdb;
}
/**
* v0.18.1 incident shape, end to end: reasoning enabled (uncapped
* effort mode), the first response burns the whole completion budget
* on thinking and returns empty content with finish_reason=length —
* the provider retries exactly once with reasoning disabled and
* returns the recovered completion.
*/
public function test_empty_length_completion_retries_once_with_reasoning_disabled(): void {
$requests = [];
$empty_response = json_encode( [
'model' => 'deepseek/deepseek-v4-flash',
'choices' => [ [ 'message' => [ 'content' => '', 'reasoning' => 'endless thinking…' ], 'finish_reason' => 'length' ] ],
'usage' => [ 'prompt_tokens' => 1200, 'completion_tokens' => 4000, 'reasoning_tokens' => 4000, 'total_tokens' => 5200 ],
] );
$good_response = json_encode( [
'model' => 'deepseek/deepseek-v4-flash',
'choices' => [ [ 'message' => [ 'content' => 'Recovered article.' ], 'finish_reason' => 'stop' ] ],
'usage' => [ 'prompt_tokens' => 1200, 'completion_tokens' => 900, 'total_tokens' => 2100 ],
] );
$this->wire_full_provider(
[
'prautoblogger_reasoning_enabled' => '1',
'prautoblogger_reasoning_effort' => 'xhigh',
'prautoblogger_reasoning_max_tokens' => 0,
],
[ $empty_response, $good_response ],
$requests
);
try {
$provider = new \PRAutoBlogger_OpenRouter_Provider();
$result = $provider->send_chat_completion(
[ [ 'role' => 'user', 'content' => 'Write the article.' ] ],
'deepseek/deepseek-v4-flash',
[ 'temperature' => 0.7, 'max_tokens' => 4000, 'stage' => 'draft' ]
);
$this->assertSame( 'Recovered article.', $result['content'] );
$this->assertCount( 2, $requests );
// First attempt: global reasoning, uncapped effort mode.
$this->assertSame( [ 'enabled' => true, 'effort' => 'xhigh' ], $requests[0]['reasoning'] );
$this->assertSame( 4000, $requests[0]['max_tokens'] );
// Retry: reasoning disabled per call.
$this->assertSame( [ 'enabled' => false ], $requests[1]['reasoning'] );
// Caller metadata never reaches the HTTP body.
$this->assertArrayNotHasKey( 'stage', $requests[0] );
$this->assertArrayNotHasKey( 'empty_retry', $requests[1] );
} finally {
unset( $GLOBALS['wpdb'] );
\PRAutoBlogger_Run_Context::clear();
}
}
/**
* v0.18.1 reasoning budget: with the token cap active (default), the
* outgoing request caps thinking via reasoning.max_tokens and raises
* max_tokens by the cap — and a healthy completion needs no retry.
*/
public function test_reasoning_cap_shapes_the_outgoing_request(): void {
$requests = [];
$good_response = json_encode( [
'model' => 'deepseek/deepseek-v4-flash',
'choices' => [ [ 'message' => [ 'content' => 'Article text.' ], 'finish_reason' => 'stop' ] ],
'usage' => [ 'prompt_tokens' => 1200, 'completion_tokens' => 900, 'total_tokens' => 2100 ],
] );
$this->wire_full_provider(
[
'prautoblogger_reasoning_enabled' => '1',
'prautoblogger_reasoning_effort' => 'xhigh',
'prautoblogger_reasoning_max_tokens' => 2048,
],
[ $good_response ],
$requests
);
try {
$provider = new \PRAutoBlogger_OpenRouter_Provider();
$result = $provider->send_chat_completion(
[ [ 'role' => 'user', 'content' => 'Write the article.' ] ],
'deepseek/deepseek-v4-flash',
[ 'temperature' => 0.7, 'max_tokens' => 4000, 'stage' => 'draft' ]
);
$this->assertSame( 'Article text.', $result['content'] );
$this->assertCount( 1, $requests );
$this->assertSame( 2048, $requests[0]['reasoning']['max_tokens'] );
$this->assertArrayNotHasKey( 'effort', $requests[0]['reasoning'] );
$this->assertSame( 6048, $requests[0]['max_tokens'] );
} finally {
unset( $GLOBALS['wpdb'] );
\PRAutoBlogger_Run_Context::clear();
}
}
}