From 5a62347bd1fd9bf89bdbaee7acfae8f81af8b044 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 11 Jun 2026 13:50:26 +0000 Subject: [PATCH] test: split PublisherTest and OpenRouterProviderTest under the 300-line rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/unit/Core/PublisherSideEffectsTest.php | 183 ++++++++++++++ tests/unit/Core/PublisherTest.php | 131 ---------- .../Providers/OpenRouterProviderRetryTest.php | 223 ++++++++++++++++++ .../unit/Providers/OpenRouterProviderTest.php | 149 ------------ 4 files changed, 406 insertions(+), 280 deletions(-) create mode 100644 tests/unit/Core/PublisherSideEffectsTest.php create mode 100644 tests/unit/Providers/OpenRouterProviderRetryTest.php diff --git a/tests/unit/Core/PublisherSideEffectsTest.php b/tests/unit/Core/PublisherSideEffectsTest.php new file mode 100644 index 0000000..20cf8c9 --- /dev/null +++ b/tests/unit/Core/PublisherSideEffectsTest.php @@ -0,0 +1,183 @@ +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( '

Content

', $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( '

Content

', $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( '

Content

', $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( '

Content

', $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( '

Content

', $this->idea, $this->review ); + } +} diff --git a/tests/unit/Core/PublisherTest.php b/tests/unit/Core/PublisherTest.php index ae14cdf..5886650 100644 --- a/tests/unit/Core/PublisherTest.php +++ b/tests/unit/Core/PublisherTest.php @@ -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( '

Content

', $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( '

Content

', $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( '

Content

', $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( '

Content

', $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( '

Content

', $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( '

Content

', $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 diff --git a/tests/unit/Providers/OpenRouterProviderRetryTest.php b/tests/unit/Providers/OpenRouterProviderRetryTest.php new file mode 100644 index 0000000..baf9892 --- /dev/null +++ b/tests/unit/Providers/OpenRouterProviderRetryTest.php @@ -0,0 +1,223 @@ +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(); + } + } +} diff --git a/tests/unit/Providers/OpenRouterProviderTest.php b/tests/unit/Providers/OpenRouterProviderTest.php index 5a5fb14..0f9d301 100644 --- a/tests/unit/Providers/OpenRouterProviderTest.php +++ b/tests/unit/Providers/OpenRouterProviderTest.php @@ -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(); - } - } }