diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ae20702..014a281 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,64 +2,24 @@ /** * PHPUnit bootstrap for PRAutoBlogger unit tests. * - * Uses Brain Monkey to mock WordPress functions so tests run without - * a full WordPress installation. Only loads the classes under test. + * Loads Brain\Monkey for WordPress function mocking, + * then requires the source files under test. * - * @see https://brain-wp.github.io/BrainMonkey/ + * @package PRAutoBlogger\Tests */ -declare(strict_types=1); +require_once dirname( __DIR__ ) . '/vendor/autoload.php'; -// Composer autoloader for Brain Monkey and test dependencies. -$autoloader = __DIR__ . '/../vendor/autoload.php'; -if ( ! file_exists( $autoloader ) ) { - echo "Run 'composer install' before running tests.\n"; - exit( 1 ); -} -require_once $autoloader; +// Brain\Monkey sets up Patchwork & function stubs. +\Brain\Monkey\setUp(); -// Define WordPress constants that plugin code expects. +// Define WordPress constants that source files expect. if ( ! defined( 'ABSPATH' ) ) { - define( 'ABSPATH', '/tmp/fake-wp/' ); + define( 'ABSPATH', '/tmp/wordpress/' ); } -if ( ! defined( 'DAY_IN_SECONDS' ) ) { - define( 'DAY_IN_SECONDS', 86400 ); +if ( ! defined( 'PRAB_VERSION' ) ) { + define( 'PRAB_VERSION', '1.0.0-test' ); } -if ( ! defined( 'HOUR_IN_SECONDS' ) ) { - define( 'HOUR_IN_SECONDS', 3600 ); +if ( ! defined( 'PRAB_PLUGIN_DIR' ) ) { + define( 'PRAB_PLUGIN_DIR', dirname( __DIR__ ) . '/' ); } - -// PRAutoBlogger constants from the main plugin file. -if ( ! defined( 'PRAUTOBLOGGER_VERSION' ) ) { - define( 'PRAUTOBLOGGER_VERSION', '0.1.0' ); -} -if ( ! defined( 'PRAUTOBLOGGER_MAX_RETRIES' ) ) { - define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 ); -} -if ( ! defined( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS' ) ) { - define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 2 ); -} -if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL' ) ) { - define( 'PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL', 'anthropic/claude-3.5-haiku' ); -} -if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_WRITING_MODEL' ) ) { - define( 'PRAUTOBLOGGER_DEFAULT_WRITING_MODEL', 'anthropic/claude-sonnet-4' ); -} -if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) { - define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'anthropic/claude-sonnet-4' ); -} - -// Load plugin class files (no autoloader in tests — explicit includes). -$includes_dir = dirname( __DIR__ ) . '/includes'; - -// Models (no WP dependencies, safe to load). -require_once $includes_dir . '/models/class-generation-log.php'; -require_once $includes_dir . '/models/class-content-score.php'; -require_once $includes_dir . '/models/class-source-data.php'; -require_once $includes_dir . '/models/class-analysis-result.php'; -require_once $includes_dir . '/models/class-article-idea.php'; -require_once $includes_dir . '/models/class-editorial-review.php'; -require_once $includes_dir . '/models/class-content-request.php'; - -// Providers (loaded on demand in tests since they need WP mocks). -// Core classes (loaded on demand in tests since they need WP mocks). diff --git a/tests/unit/ArticleIdeaTest.php b/tests/unit/ArticleIdeaTest.php deleted file mode 100755 index 4fed4d8..0000000 --- a/tests/unit/ArticleIdeaTest.php +++ /dev/null @@ -1,65 +0,0 @@ - 'BPC-157 dosage protocols', - 'article_type' => 'question', - 'suggested_title' => 'BPC-157 Dosage Guide: What the Research Says', - 'summary' => 'Recurring questions about optimal dosing.', - 'score' => 0.85, - 'analysis_id' => 7, - 'source_ids' => [ 101, 102, 103 ], - 'key_points' => [ 'Dosing ranges', 'Cycling protocols', 'Stacking' ], - 'target_keywords' => [ 'bpc-157 dosage', 'bpc-157 protocol' ], - ]; - - $idea = new PRAutoBlogger_Article_Idea( $data ); - - $this->assertSame( 'BPC-157 dosage protocols', $idea->get_topic() ); - $this->assertSame( 'question', $idea->get_article_type() ); - $this->assertSame( 'BPC-157 Dosage Guide: What the Research Says', $idea->get_suggested_title() ); - $this->assertSame( 'Recurring questions about optimal dosing.', $idea->get_summary() ); - $this->assertEqualsWithDelta( 0.85, $idea->get_score(), 0.001 ); - $this->assertSame( 7, $idea->get_analysis_id() ); - $this->assertSame( [ 101, 102, 103 ], $idea->get_source_ids() ); - $this->assertCount( 3, $idea->get_key_points() ); - $this->assertCount( 2, $idea->get_target_keywords() ); - } - - public function test_optional_fields_default_to_empty(): void { - $idea = new PRAutoBlogger_Article_Idea( [ - 'topic' => 'Test', - 'article_type' => 'comparison', - 'suggested_title' => 'Test Title', - 'summary' => 'Test summary', - ] ); - - $this->assertEqualsWithDelta( 0.0, $idea->get_score(), 0.001 ); - $this->assertSame( 0, $idea->get_analysis_id() ); - $this->assertSame( [], $idea->get_source_ids() ); - $this->assertSame( [], $idea->get_key_points() ); - $this->assertSame( [], $idea->get_target_keywords() ); - } - - public function test_score_cast_from_string(): void { - $idea = new PRAutoBlogger_Article_Idea( [ - 'topic' => 'Test', - 'article_type' => 'question', - 'suggested_title' => 'Test', - 'summary' => 'Test', - 'score' => '0.75', - ] ); - - $this->assertIsFloat( $idea->get_score() ); - $this->assertEqualsWithDelta( 0.75, $idea->get_score(), 0.001 ); - } -} diff --git a/tests/unit/BaseTestCase.php b/tests/unit/BaseTestCase.php new file mode 100644 index 0000000..8b949e9 --- /dev/null +++ b/tests/unit/BaseTestCase.php @@ -0,0 +1,82 @@ + value. + * + * @param array $options Key-value pairs of option names and their return values. + */ + protected function stub_get_option( array $options ): void { + Functions\when( 'get_option' )->alias( + function ( string $name, $default = false ) use ( $options ) { + return $options[ $name ] ?? $default; + } + ); + } + + /** + * Helper: stub current_time to return a fixed timestamp. + * + * @param string $time MySQL datetime string (Y-m-d H:i:s). + */ + protected function stub_current_time( string $time ): void { + Functions\when( 'current_time' )->alias( + function ( $type ) use ( $time ) { + return 'mysql' === $type ? $time : strtotime( $time ); + } + ); + } + + /** + * Helper: create a mock $wpdb with expectations. + * + * @return \PHPUnit\Framework\MockObject\MockObject + */ + protected function create_mock_wpdb() { + $wpdb = $this->getMockBuilder( \stdClass::class ) + ->addMethods( [ 'prepare', 'get_var', 'get_results', 'insert', 'query', 'get_row' ] ) + ->getMock(); + + $wpdb->prefix = 'wp_'; + $wpdb->insert_id = 0; + $wpdb->last_error = ''; + $wpdb->prab_cost_logs = 'wp_prab_cost_logs'; + + return $wpdb; + } +} diff --git a/tests/unit/Core/ContentAnalyzerTest.php b/tests/unit/Core/ContentAnalyzerTest.php new file mode 100644 index 0000000..a4b3cc8 --- /dev/null +++ b/tests/unit/Core/ContentAnalyzerTest.php @@ -0,0 +1,166 @@ +mock_provider = $this->createMock( \PRAutoBlogger_LLM_Provider::class ); + + // Mock the cost tracker. + $this->mock_cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); + } + + /** + * Test analyze returns AnalysisResult on successful LLM response. + */ + public function test_analyze_returns_analysis_result_on_success(): void { + $source = new \PRAutoBlogger_Source_Data( + 'BPC-157 Shows Promise in Gut Healing', + 'https://example.com/bpc157', + 'BPC-157 is a synthetic peptide that has shown remarkable healing properties...', + 'rss' + ); + + $llm_response = [ + 'content' => json_encode( [ + 'category' => 'peptide-research', + 'keywords' => [ 'BPC-157', 'gut healing', 'peptide therapy' ], + 'relevance_score' => 9, + 'summary' => 'Article discusses BPC-157 healing properties for gut issues.', + 'topics' => [ 'regenerative medicine', 'gastrointestinal health' ], + ] ), + 'usage' => [ + 'prompt_tokens' => 200, + 'completion_tokens' => 150, + ], + ]; + + $this->mock_provider->expects( $this->once() ) + ->method( 'chat_completion' ) + ->willReturn( $llm_response ); + + $this->mock_cost_tracker->expects( $this->once() ) + ->method( 'log_api_call' ); + + $analyzer = new \PRAutoBlogger_Content_Analyzer( + $this->mock_provider, + $this->mock_cost_tracker + ); + + $result = $analyzer->analyze( $source ); + + $this->assertInstanceOf( \PRAutoBlogger_Analysis_Result::class, $result ); + $this->assertSame( 'peptide-research', $result->get_category() ); + $this->assertSame( 9, $result->get_relevance_score() ); + $this->assertContains( 'BPC-157', $result->get_keywords() ); + } + + /** + * Test analyze handles LLM error response gracefully. + */ + public function test_analyze_handles_llm_error(): void { + $source = new \PRAutoBlogger_Source_Data( + 'Test Article', + 'https://example.com', + 'Content.', + 'rss' + ); + + $this->mock_provider->expects( $this->once() ) + ->method( 'chat_completion' ) + ->willReturn( [ 'error' => 'Rate limit exceeded' ] ); + + $this->mock_cost_tracker->expects( $this->once() ) + ->method( 'log_api_call' ) + ->with( $this->callback( function ( $data ) { + return false === $data['success']; + } ) ); + + $analyzer = new \PRAutoBlogger_Content_Analyzer( + $this->mock_provider, + $this->mock_cost_tracker + ); + + $result = $analyzer->analyze( $source ); + + $this->assertNull( $result ); + } + + /** + * Test analyze handles malformed JSON in LLM content. + */ + public function test_analyze_handles_malformed_llm_json(): void { + $source = new \PRAutoBlogger_Source_Data( + 'Test', 'https://example.com', 'Content.', 'rss' + ); + + $this->mock_provider->method( 'chat_completion' ) + ->willReturn( [ + 'content' => 'This is not valid JSON {{{', + 'usage' => [ 'prompt_tokens' => 100, 'completion_tokens' => 50 ], + ] ); + + $analyzer = new \PRAutoBlogger_Content_Analyzer( + $this->mock_provider, + $this->mock_cost_tracker + ); + + $result = $analyzer->analyze( $source ); + + $this->assertNull( $result ); + } + + /** + * Test analyze respects budget check before calling LLM. + */ + public function test_analyze_skips_when_budget_exceeded(): void { + $source = new \PRAutoBlogger_Source_Data( + 'Test', 'https://example.com', 'Content.', 'rss' + ); + + $this->mock_cost_tracker->method( 'is_budget_exceeded' )->willReturn( true ); + + // Provider should never be called if budget is exceeded. + $this->mock_provider->expects( $this->never() ) + ->method( 'chat_completion' ); + + $analyzer = new \PRAutoBlogger_Content_Analyzer( + $this->mock_provider, + $this->mock_cost_tracker + ); + + $result = $analyzer->analyze( $source ); + + $this->assertNull( $result ); + } +} diff --git a/tests/unit/Core/CostTrackerTest.php b/tests/unit/Core/CostTrackerTest.php new file mode 100644 index 0000000..d55a654 --- /dev/null +++ b/tests/unit/Core/CostTrackerTest.php @@ -0,0 +1,275 @@ +wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + + require_once PRAB_PLUGIN_DIR . 'includes/core/class-prab-cost-tracker.php'; + } + + protected function tearDown(): void { + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + // --------------------------------------------------------------- + // log_api_call + // --------------------------------------------------------------- + + /** + * Test log_api_call inserts a row with all required fields. + */ + public function test_log_api_call_inserts_complete_row(): void { + $this->stub_current_time( '2026-04-11 14:30:00' ); + + $data = [ + 'model' => 'google/gemini-2.0-flash-001', + 'step' => 'content_generation', + 'prompt_tokens' => 500, + 'completion_tokens' => 1200, + 'cost' => 0.0034, + 'duration' => 2.45, + 'success' => true, + 'error' => '', + ]; + + $this->wpdb->expects( $this->once() ) + ->method( 'insert' ) + ->with( + 'wp_prab_cost_logs', + $this->callback( function ( $row ) use ( $data ) { + return $row['model'] === $data['model'] + && $row['prompt_tokens'] === $data['prompt_tokens'] + && $row['completion_tokens'] === $data['completion_tokens'] + && abs( $row['cost'] - $data['cost'] ) < 0.0001 + && $row['step'] === $data['step']; + } ) + ) + ->willReturn( 1 ); + + $tracker = new \PRAutoBlogger_Cost_Tracker(); + $result = $tracker->log_api_call( $data ); + + $this->assertTrue( $result ); + } + + /** + * Test log_api_call returns false on DB insert failure. + */ + public function test_log_api_call_returns_false_on_db_failure(): void { + $this->stub_current_time( '2026-04-11 14:30:00' ); + + $this->wpdb->expects( $this->once() ) + ->method( 'insert' ) + ->willReturn( false ); + + $tracker = new \PRAutoBlogger_Cost_Tracker(); + $result = $tracker->log_api_call( [ + 'model' => 'test/model', + 'step' => 'test', + 'prompt_tokens' => 100, + 'completion_tokens' => 200, + 'cost' => 0.001, + 'duration' => 1.0, + 'success' => true, + 'error' => '', + ] ); + + $this->assertFalse( $result ); + } + + // --------------------------------------------------------------- + // get_monthly_spend + // --------------------------------------------------------------- + + /** + * Test get_monthly_spend returns aggregated cost for given month. + */ + public function test_get_monthly_spend_returns_sum(): void { + $this->wpdb->expects( $this->once() ) + ->method( 'prepare' ) + ->willReturn( "SELECT SUM(cost) FROM wp_prab_cost_logs WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 4" ); + + $this->wpdb->expects( $this->once() ) + ->method( 'get_var' ) + ->willReturn( '12.5600' ); + + $tracker = new \PRAutoBlogger_Cost_Tracker(); + $spend = $tracker->get_monthly_spend( 2026, 4 ); + + $this->assertSame( 12.56, round( $spend, 2 ) ); + } + + /** + * Test get_monthly_spend returns zero when no rows exist. + */ + public function test_get_monthly_spend_returns_zero_when_empty(): void { + $this->wpdb->method( 'prepare' )->willReturn( 'prepared query' ); + $this->wpdb->method( 'get_var' )->willReturn( null ); + + $tracker = new \PRAutoBlogger_Cost_Tracker(); + $spend = $tracker->get_monthly_spend( 2026, 1 ); + + $this->assertSame( 0.0, (float) $spend ); + } + + // --------------------------------------------------------------- + // is_budget_exceeded + // --------------------------------------------------------------- + + /** + * Test is_budget_exceeded returns false when budget is unlimited (0). + */ + public function test_is_budget_exceeded_returns_false_when_unlimited(): void { + $this->stub_get_option( [ + 'prab_settings' => [ 'monthly_budget' => 0 ], + ] ); + + // Should not even query the DB when budget is unlimited. + $this->wpdb->expects( $this->never() ) + ->method( 'get_var' ); + + $tracker = new \PRAutoBlogger_Cost_Tracker(); + $this->assertFalse( $tracker->is_budget_exceeded() ); + } + + /** + * Test is_budget_exceeded returns true when spend exceeds budget. + */ + public function test_is_budget_exceeded_returns_true_when_over(): void { + $this->stub_get_option( [ + 'prab_settings' => [ 'monthly_budget' => 10.00 ], + ] ); + + $this->wpdb->method( 'prepare' )->willReturn( 'prepared' ); + $this->wpdb->method( 'get_var' )->willReturn( '15.75' ); + + $tracker = new \PRAutoBlogger_Cost_Tracker(); + $this->assertTrue( $tracker->is_budget_exceeded() ); + } + + /** + * Test is_budget_exceeded returns false when under budget. + */ + public function test_is_budget_exceeded_returns_false_when_under(): void { + $this->stub_get_option( [ + 'prab_settings' => [ 'monthly_budget' => 50.00 ], + ] ); + + $this->wpdb->method( 'prepare' )->willReturn( 'prepared' ); + $this->wpdb->method( 'get_var' )->willReturn( '23.40' ); + + $tracker = new \PRAutoBlogger_Cost_Tracker(); + $this->assertFalse( $tracker->is_budget_exceeded() ); + } + + // --------------------------------------------------------------- + // estimate_cost + // --------------------------------------------------------------- + + /** + * Test estimate_cost calculates based on model pricing. + */ + public function test_estimate_cost_calculates_correctly(): void { + $tracker = new \PRAutoBlogger_Cost_Tracker(); + + // Using a known model — the exact price depends on the pricing table, + // but the structure should be: (prompt_tokens * input_price + completion_tokens * output_price) / 1_000_000 + $cost = $tracker->estimate_cost( 'google/gemini-2.0-flash-001', 1000, 2000 ); + + $this->assertIsFloat( $cost ); + $this->assertGreaterThanOrEqual( 0.0, $cost ); + } + + /** + * Test estimate_cost returns zero for unknown model. + */ + public function test_estimate_cost_returns_zero_for_unknown_model(): void { + $tracker = new \PRAutoBlogger_Cost_Tracker(); + + $cost = $tracker->estimate_cost( 'nonexistent/model-xyz', 1000, 2000 ); + + $this->assertSame( 0.0, $cost ); + } + + // --------------------------------------------------------------- + // get_recent_logs + // --------------------------------------------------------------- + + /** + * Test get_recent_logs returns array of recent entries. + */ + public function test_get_recent_logs_returns_limited_results(): void { + $mock_rows = [ + (object) [ + 'id' => 1, + 'model' => 'model/a', + 'step' => 'analysis', + 'prompt_tokens' => 100, + 'completion_tokens' => 200, + 'cost' => 0.001, + 'duration' => 0.5, + 'success' => 1, + 'error' => '', + 'created_at' => '2026-04-11 14:00:00', + ], + (object) [ + 'id' => 2, + 'model' => 'model/b', + 'step' => 'generation', + 'prompt_tokens' => 500, + 'completion_tokens' => 1000, + 'cost' => 0.005, + 'duration' => 2.1, + 'success' => 1, + 'error' => '', + 'created_at' => '2026-04-11 14:05:00', + ], + ]; + + $this->wpdb->method( 'prepare' )->willReturn( 'prepared' ); + $this->wpdb->method( 'get_results' )->willReturn( $mock_rows ); + + $tracker = new \PRAutoBlogger_Cost_Tracker(); + $logs = $tracker->get_recent_logs( 10 ); + + $this->assertIsArray( $logs ); + $this->assertCount( 2, $logs ); + } + + /** + * Test get_recent_logs returns empty array when no logs exist. + */ + public function test_get_recent_logs_returns_empty_when_no_rows(): void { + $this->wpdb->method( 'prepare' )->willReturn( 'prepared' ); + $this->wpdb->method( 'get_results' )->willReturn( [] ); + + $tracker = new \PRAutoBlogger_Cost_Tracker(); + $logs = $tracker->get_recent_logs( 10 ); + + $this->assertIsArray( $logs ); + $this->assertEmpty( $logs ); + } +} diff --git a/tests/unit/Core/IdeaScorerTest.php b/tests/unit/Core/IdeaScorerTest.php new file mode 100644 index 0000000..49fabe3 --- /dev/null +++ b/tests/unit/Core/IdeaScorerTest.php @@ -0,0 +1,117 @@ +rank_ideas( $ideas ); + + $this->assertSame( 'High Score', $ranked[0]->get_title() ); + $this->assertSame( 'Mid Score', $ranked[1]->get_title() ); + $this->assertSame( 'Low Score', $ranked[2]->get_title() ); + } + + /** + * Test rank_ideas limits output to requested count. + */ + public function test_rank_ideas_limits_results(): void { + $ideas = []; + for ( $i = 0; $i < 20; $i++ ) { + $ideas[] = new \PRAutoBlogger_Article_Idea( + "Idea {$i}", 'Description', [], 'cat', (float) $i + ); + } + + $scorer = new \PRAutoBlogger_Idea_Scorer(); + $ranked = $scorer->rank_ideas( $ideas, 5 ); + + $this->assertCount( 5, $ranked ); + // Should be the top 5 by score. + $this->assertSame( 'Idea 19', $ranked[0]->get_title() ); + } + + /** + * Test deduplicate removes ideas with similar titles. + */ + public function test_deduplicate_removes_similar_titles(): void { + $ideas = [ + new \PRAutoBlogger_Article_Idea( 'BPC-157 Benefits for Gut Health', 'Desc', [], 'cat', 80.0 ), + new \PRAutoBlogger_Article_Idea( 'BPC-157 Benefits for Gut Health', 'Different desc', [], 'cat', 75.0 ), + new \PRAutoBlogger_Article_Idea( 'Thymosin Beta-4 Research Update', 'Desc', [], 'cat', 70.0 ), + ]; + + $scorer = new \PRAutoBlogger_Idea_Scorer(); + $deduplicated = $scorer->deduplicate( $ideas ); + + $this->assertCount( 2, $deduplicated ); + } + + /** + * Test deduplicate keeps the higher-scored duplicate. + */ + public function test_deduplicate_keeps_higher_score(): void { + $ideas = [ + new \PRAutoBlogger_Article_Idea( 'Same Title Here', 'Low version', [], 'cat', 40.0 ), + new \PRAutoBlogger_Article_Idea( 'Same Title Here', 'High version', [], 'cat', 90.0 ), + ]; + + $scorer = new \PRAutoBlogger_Idea_Scorer(); + $deduplicated = $scorer->deduplicate( $ideas ); + + $this->assertCount( 1, $deduplicated ); + $this->assertSame( 90.0, $deduplicated[0]->get_score() ); + } + + /** + * Test rank_ideas handles empty input. + */ + public function test_rank_ideas_handles_empty_input(): void { + $scorer = new \PRAutoBlogger_Idea_Scorer(); + $ranked = $scorer->rank_ideas( [] ); + + $this->assertIsArray( $ranked ); + $this->assertEmpty( $ranked ); + } + + /** + * Test rank_ideas handles single idea. + */ + public function test_rank_ideas_handles_single_idea(): void { + $ideas = [ + new \PRAutoBlogger_Article_Idea( 'Only Idea', 'Desc', [], 'cat', 50.0 ), + ]; + + $scorer = new \PRAutoBlogger_Idea_Scorer(); + $ranked = $scorer->rank_ideas( $ideas ); + + $this->assertCount( 1, $ranked ); + $this->assertSame( 'Only Idea', $ranked[0]->get_title() ); + } +} diff --git a/tests/unit/Core/PublisherTest.php b/tests/unit/Core/PublisherTest.php new file mode 100644 index 0000000..7ec64a4 --- /dev/null +++ b/tests/unit/Core/PublisherTest.php @@ -0,0 +1,159 @@ + 'BPC-157: A Complete Guide', + 'content' => '
BPC-157 is a synthetic peptide...
', + 'excerpt' => 'An overview of BPC-157 benefits.', + 'category' => 'peptide-research', + ]; + + $generation_meta = [ + 'model' => 'google/gemini-2.0-flash-001', + 'pipeline_mode' => 'single_pass', + 'total_cost' => 0.0045, + 'prompt_tokens' => 800, + 'completion_tokens' => 2000, + 'generation_logs' => [], + ]; + + // wp_insert_post should be called with post_status = 'draft'. + Functions\expect( 'wp_insert_post' ) + ->once() + ->with( \Mockery::on( function ( $args ) { + return 'draft' === $args['post_status'] + && 'BPC-157: A Complete Guide' === $args['post_title'] + && false !== strpos( $args['post_content'], 'BPC-157' ); + } ) ) + ->andReturn( 42 ); + + // Metadata should be saved. + Functions\expect( 'update_post_meta' ) + ->atLeast() + ->once(); + + Functions\when( 'is_wp_error' )->justReturn( false ); + Functions\when( 'wp_slash' )->returnArg(); + Functions\when( 'sanitize_title' )->alias( function ( $title ) { + return strtolower( str_replace( ' ', '-', $title ) ); + } ); + + $publisher = new \PRAutoBlogger_Publisher(); + $post_id = $publisher->publish( $post_data, $generation_meta ); + + $this->assertSame( 42, $post_id ); + } + + /** + * Test publish returns WP_Error when wp_insert_post fails. + */ + public function test_publish_returns_error_on_insert_failure(): void { + $wp_error = new \stdClass(); + $wp_error->errors = [ 'db_insert_error' => [ 'Could not insert post' ] ]; + + Functions\when( 'wp_insert_post' )->justReturn( $wp_error ); + Functions\when( 'is_wp_error' )->justReturn( true ); + Functions\when( 'wp_slash' )->returnArg(); + Functions\when( 'sanitize_title' )->returnArg(); + + $publisher = new \PRAutoBlogger_Publisher(); + $result = $publisher->publish( + [ 'title' => 'Test', 'content' => 'Test content', 'excerpt' => '', 'category' => '' ], + [] + ); + + // Should propagate the error, not the post ID. + $this->assertNotSame( 42, $result ); + } + + /** + * Test publish sanitizes title and content. + */ + public function test_publish_calls_sanitization(): void { + Functions\expect( 'wp_slash' ) + ->atLeast() + ->once() + ->andReturnFirstArg(); + + Functions\expect( 'sanitize_title' ) + ->once() + ->andReturnUsing( function ( $title ) { + return strtolower( str_replace( ' ', '-', $title ) ); + } ); + + Functions\when( 'wp_insert_post' )->justReturn( 1 ); + Functions\when( 'is_wp_error' )->justReturn( false ); + Functions\when( 'update_post_meta' )->justReturn( true ); + + $publisher = new \PRAutoBlogger_Publisher(); + $publisher->publish( + [ + 'title' => 'Test ', + 'content' => 'Safe content
', + 'excerpt' => 'Excerpt', + 'category' => 'general', + ], + [] + ); + + // If we get here without error, sanitization was called. + $this->assertTrue( true ); + } + + /** + * Test publish stores generation metadata as post meta. + */ + public function test_publish_stores_generation_metadata(): void { + $generation_meta = [ + 'model' => 'test/model', + 'pipeline_mode' => 'multi_step', + 'total_cost' => 0.01, + ]; + + Functions\when( 'wp_insert_post' )->justReturn( 99 ); + Functions\when( 'is_wp_error' )->justReturn( false ); + Functions\when( 'wp_slash' )->returnArg(); + Functions\when( 'sanitize_title' )->returnArg(); + + $meta_saved = []; + Functions\when( 'update_post_meta' )->alias( + function ( $post_id, $key, $value ) use ( &$meta_saved ) { + $meta_saved[ $key ] = $value; + return true; + } + ); + + $publisher = new \PRAutoBlogger_Publisher(); + $publisher->publish( + [ 'title' => 'T', 'content' => 'C', 'excerpt' => '', 'category' => '' ], + $generation_meta + ); + + // Verify generation metadata was stored. + $this->assertNotEmpty( $meta_saved ); + } +} diff --git a/tests/unit/EditorialReviewTest.php b/tests/unit/EditorialReviewTest.php deleted file mode 100755 index 872e455..0000000 --- a/tests/unit/EditorialReviewTest.php +++ /dev/null @@ -1,86 +0,0 @@ - 'approved', - 'notes' => 'Good article, ready to publish.', - 'quality_score' => 0.85, - 'seo_score' => 0.78, - 'issues' => [], - ] ); - - $this->assertSame( 'approved', $review->get_verdict() ); - $this->assertSame( 'Good article, ready to publish.', $review->get_notes() ); - $this->assertNull( $review->get_revised_content() ); - $this->assertEqualsWithDelta( 0.85, $review->get_quality_score(), 0.01 ); - $this->assertEqualsWithDelta( 0.78, $review->get_seo_score(), 0.01 ); - $this->assertEmpty( $review->get_issues() ); - } - - public function test_revised_review_with_content(): void { - $review = new PRAutoBlogger_Editorial_Review( [ - 'verdict' => 'revised', - 'notes' => 'Fixed heading structure.', - 'revised_content' => 'Better content.
', - 'quality_score' => 0.80, - 'seo_score' => 0.90, - 'issues' => [ 'H2 tags were missing' ], - ] ); - - $this->assertSame( 'revised', $review->get_verdict() ); - $this->assertNotNull( $review->get_revised_content() ); - $this->assertStringContainsString( 'Improved Article', $review->get_revised_content() ); - $this->assertCount( 1, $review->get_issues() ); - } - - /** - * Edge case: "revised" verdict but null revised_content. - * This happened in production when the LLM returned a malformed response. - * The pipeline handles this with a null-coalesce fallback. - */ - public function test_revised_verdict_with_null_content(): void { - $review = new PRAutoBlogger_Editorial_Review( [ - 'verdict' => 'revised', - 'notes' => 'Supposedly revised but no content provided.', - 'revised_content' => null, - ] ); - - $this->assertSame( 'revised', $review->get_verdict() ); - $this->assertNull( $review->get_revised_content() ); - } - - public function test_rejected_review(): void { - $review = new PRAutoBlogger_Editorial_Review( [ - 'verdict' => 'rejected', - 'notes' => 'Off-topic and factually inaccurate.', - 'quality_score' => 0.2, - 'seo_score' => 0.1, - 'issues' => [ 'Off-topic', 'Factual errors', 'Poor structure' ], - ] ); - - $this->assertSame( 'rejected', $review->get_verdict() ); - $this->assertCount( 3, $review->get_issues() ); - $this->assertLessThan( 0.5, $review->get_quality_score() ); - } - - public function test_default_scores_are_zero(): void { - $review = new PRAutoBlogger_Editorial_Review( [ - 'verdict' => 'rejected', - 'notes' => 'Minimal data.', - ] ); - - $this->assertEqualsWithDelta( 0.0, $review->get_quality_score(), 0.01 ); - $this->assertEqualsWithDelta( 0.0, $review->get_seo_score(), 0.01 ); - $this->assertEmpty( $review->get_issues() ); - } -} diff --git a/tests/unit/GenerationLogTest.php b/tests/unit/GenerationLogTest.php deleted file mode 100755 index a314c23..0000000 --- a/tests/unit/GenerationLogTest.php +++ /dev/null @@ -1,128 +0,0 @@ -justReturn( '2026-04-10 12:00:00' ); - } - - protected function tearDown(): void { - Monkey\tearDown(); - parent::tearDown(); - } - - public function test_constructor_sets_all_fields(): void { - $data = [ - 'id' => 42, - 'post_id' => 100, - 'run_id' => 'abc-123', - 'stage' => 'draft', - 'provider' => 'OpenRouter', - 'model' => 'anthropic/claude-sonnet-4', - 'prompt_tokens' => 1500, - 'completion_tokens' => 800, - 'estimated_cost' => 0.0165, - 'request_json' => '{"test": true}', - 'response_status' => 'success', - 'error_message' => null, - 'created_at' => '2026-04-10 10:00:00', - ]; - - $log = new PRAutoBlogger_Generation_Log( $data ); - - $this->assertSame( 42, $log->get_id() ); - $this->assertSame( 100, $log->get_post_id() ); - $this->assertSame( 'abc-123', $log->get_run_id() ); - $this->assertSame( 'draft', $log->get_stage() ); - $this->assertSame( 'OpenRouter', $log->get_provider() ); - $this->assertSame( 'anthropic/claude-sonnet-4', $log->get_model() ); - $this->assertSame( 1500, $log->get_prompt_tokens() ); - $this->assertSame( 800, $log->get_completion_tokens() ); - $this->assertEqualsWithDelta( 0.0165, $log->get_estimated_cost(), 0.0001 ); - $this->assertSame( 'success', $log->get_response_status() ); - $this->assertNull( $log->get_error_message() ); - $this->assertSame( '2026-04-10 10:00:00', $log->get_created_at() ); - } - - public function test_constructor_defaults_for_missing_fields(): void { - $log = new PRAutoBlogger_Generation_Log( [] ); - - $this->assertSame( 0, $log->get_id() ); - $this->assertNull( $log->get_post_id() ); - $this->assertNull( $log->get_run_id() ); - $this->assertSame( '', $log->get_stage() ); - $this->assertSame( 0, $log->get_prompt_tokens() ); - $this->assertSame( 0, $log->get_completion_tokens() ); - $this->assertEqualsWithDelta( 0.0, $log->get_estimated_cost(), 0.0001 ); - $this->assertSame( 'success', $log->get_response_status() ); - $this->assertSame( '2026-04-10 12:00:00', $log->get_created_at() ); - } - - public function test_to_db_row_excludes_id(): void { - $log = new PRAutoBlogger_Generation_Log( [ - 'id' => 99, - 'post_id' => 50, - 'run_id' => 'run-abc', - 'stage' => 'review', - 'provider' => 'OpenRouter', - 'model' => 'anthropic/claude-3.5-haiku', - ] ); - - $row = $log->to_db_row(); - - // ID should NOT be in the DB row (it's auto-increment). - $this->assertArrayNotHasKey( 'id', $row ); - - // Key fields should be present. - $this->assertSame( 50, $row['post_id'] ); - $this->assertSame( 'run-abc', $row['run_id'] ); - $this->assertSame( 'review', $row['stage'] ); - $this->assertSame( 'OpenRouter', $row['provider'] ); - } - - public function test_to_db_row_has_all_expected_keys(): void { - $log = new PRAutoBlogger_Generation_Log( [ 'stage' => 'analysis' ] ); - $row = $log->to_db_row(); - - $expected_keys = [ - 'post_id', 'run_id', 'stage', 'provider', 'model', - 'prompt_tokens', 'completion_tokens', 'estimated_cost', - 'request_json', 'response_status', 'error_message', 'created_at', - ]; - - foreach ( $expected_keys as $key ) { - $this->assertArrayHasKey( $key, $row, "Missing key: {$key}" ); - } - } - - public function test_numeric_fields_are_cast_correctly(): void { - $log = new PRAutoBlogger_Generation_Log( [ - 'id' => '42', - 'post_id' => '100', - 'prompt_tokens' => '1500', - 'completion_tokens' => '800', - 'estimated_cost' => '0.0165', - ] ); - - // String values should be cast to proper types. - $this->assertSame( 42, $log->get_id() ); - $this->assertSame( 100, $log->get_post_id() ); - $this->assertSame( 1500, $log->get_prompt_tokens() ); - $this->assertSame( 800, $log->get_completion_tokens() ); - $this->assertIsFloat( $log->get_estimated_cost() ); - } -} diff --git a/tests/unit/Models/AnalysisResultTest.php b/tests/unit/Models/AnalysisResultTest.php new file mode 100644 index 0000000..d8d5621 --- /dev/null +++ b/tests/unit/Models/AnalysisResultTest.php @@ -0,0 +1,69 @@ +assertSame( 'peptide-research', $result->get_category() ); + $this->assertSame( [ 'BPC-157', 'healing' ], $result->get_keywords() ); + $this->assertSame( 8, $result->get_relevance_score() ); + $this->assertSame( 'High relevance to target audience.', $result->get_summary() ); + $this->assertSame( [ 'regenerative medicine', 'peptide therapy' ], $result->get_topics() ); + } + + /** + * Test relevance score is clamped between 0 and 10. + */ + public function test_relevance_score_boundaries(): void { + $high = new \PRAutoBlogger_Analysis_Result( 'cat', [], 10, 'Max score.', [] ); + $this->assertSame( 10, $high->get_relevance_score() ); + + $low = new \PRAutoBlogger_Analysis_Result( 'cat', [], 0, 'Min score.', [] ); + $this->assertSame( 0, $low->get_relevance_score() ); + } + + /** + * Test to_array returns complete representation. + */ + public function test_to_array_completeness(): void { + $result = new \PRAutoBlogger_Analysis_Result( + 'news', + [ 'test' ], + 5, + 'Summary text.', + [ 'topic1' ] + ); + + $array = $result->to_array(); + $this->assertIsArray( $array ); + $this->assertArrayHasKey( 'category', $array ); + $this->assertArrayHasKey( 'keywords', $array ); + $this->assertArrayHasKey( 'relevance_score', $array ); + $this->assertArrayHasKey( 'summary', $array ); + $this->assertArrayHasKey( 'topics', $array ); + } +} diff --git a/tests/unit/Models/ArticleIdeaTest.php b/tests/unit/Models/ArticleIdeaTest.php new file mode 100644 index 0000000..4c9be62 --- /dev/null +++ b/tests/unit/Models/ArticleIdeaTest.php @@ -0,0 +1,73 @@ +assertSame( 'The Future of BPC-157 Research', $idea->get_title() ); + $this->assertSame( 'An in-depth look at recent clinical trials.', $idea->get_description() ); + $this->assertSame( [ 'BPC-157', 'clinical trials', 'peptides' ], $idea->get_keywords() ); + $this->assertSame( 'peptide-research', $idea->get_category() ); + $this->assertSame( 85.5, $idea->get_score() ); + } + + /** + * Test to_array returns all fields. + */ + public function test_to_array_returns_complete_data(): void { + $idea = new \PRAutoBlogger_Article_Idea( + 'Test Title', + 'Test description.', + [ 'kw1' ], + 'general', + 50.0 + ); + + $array = $idea->to_array(); + $this->assertIsArray( $array ); + $this->assertArrayHasKey( 'title', $array ); + $this->assertArrayHasKey( 'description', $array ); + $this->assertArrayHasKey( 'keywords', $array ); + $this->assertArrayHasKey( 'category', $array ); + $this->assertArrayHasKey( 'score', $array ); + } + + /** + * Test score can be zero. + */ + public function test_allows_zero_score(): void { + $idea = new \PRAutoBlogger_Article_Idea( + 'Low Score Idea', + 'Not great.', + [], + 'general', + 0.0 + ); + + $this->assertSame( 0.0, $idea->get_score() ); + } +} diff --git a/tests/unit/Models/ContentRequestTest.php b/tests/unit/Models/ContentRequestTest.php new file mode 100644 index 0000000..ff8a370 --- /dev/null +++ b/tests/unit/Models/ContentRequestTest.php @@ -0,0 +1,70 @@ + 'informative', 'length' => 'long' ] + ); + + $this->assertSame( 'Write about BPC-157 benefits', $request->get_prompt() ); + $this->assertSame( 'single_pass', $request->get_pipeline_mode() ); + $this->assertSame( 'google/gemini-2.0-flash-001', $request->get_model() ); + $this->assertSame( 'informative', $request->get_option( 'tone' ) ); + $this->assertSame( 'long', $request->get_option( 'length' ) ); + } + + /** + * Test get_option returns default for missing keys. + */ + public function test_get_option_returns_default_for_missing_key(): void { + $request = new \PRAutoBlogger_Content_Request( + 'prompt', + 'single_pass', + 'model/name', + [] + ); + + $this->assertNull( $request->get_option( 'nonexistent' ) ); + $this->assertSame( 'fallback', $request->get_option( 'nonexistent', 'fallback' ) ); + } + + /** + * Test to_array completeness. + */ + public function test_to_array_returns_all_fields(): void { + $request = new \PRAutoBlogger_Content_Request( + 'test prompt', + 'multi_step', + 'model/x', + [ 'key' => 'val' ] + ); + + $array = $request->to_array(); + $this->assertIsArray( $array ); + $this->assertArrayHasKey( 'prompt', $array ); + $this->assertArrayHasKey( 'pipeline_mode', $array ); + $this->assertArrayHasKey( 'model', $array ); + $this->assertArrayHasKey( 'options', $array ); + } +} diff --git a/tests/unit/Models/ContentScoreTest.php b/tests/unit/Models/ContentScoreTest.php new file mode 100644 index 0000000..5304e8b --- /dev/null +++ b/tests/unit/Models/ContentScoreTest.php @@ -0,0 +1,62 @@ +assertSame( 8.0, $score->get_relevance() ); + $this->assertSame( 7.5, $score->get_quality() ); + $this->assertSame( 9.0, $score->get_originality() ); + $this->assertSame( 6.5, $score->get_seo() ); + $this->assertSame( 7.75, $score->get_overall() ); + } + + /** + * Test boundary scores (0 and 10). + */ + public function test_boundary_scores(): void { + $min = new \PRAutoBlogger_Content_Score( 0.0, 0.0, 0.0, 0.0, 0.0 ); + $this->assertSame( 0.0, $min->get_overall() ); + + $max = new \PRAutoBlogger_Content_Score( 10.0, 10.0, 10.0, 10.0, 10.0 ); + $this->assertSame( 10.0, $max->get_overall() ); + } + + /** + * Test to_array completeness. + */ + public function test_to_array_returns_all_dimensions(): void { + $score = new \PRAutoBlogger_Content_Score( 5.0, 5.0, 5.0, 5.0, 5.0 ); + + $array = $score->to_array(); + $this->assertArrayHasKey( 'relevance', $array ); + $this->assertArrayHasKey( 'quality', $array ); + $this->assertArrayHasKey( 'originality', $array ); + $this->assertArrayHasKey( 'seo', $array ); + $this->assertArrayHasKey( 'overall', $array ); + } +} diff --git a/tests/unit/Models/EditorialReviewTest.php b/tests/unit/Models/EditorialReviewTest.php new file mode 100644 index 0000000..905023e --- /dev/null +++ b/tests/unit/Models/EditorialReviewTest.php @@ -0,0 +1,70 @@ +assertTrue( $review->is_approved() ); + $this->assertSame( 'Content meets quality standards.', $review->get_feedback() ); + $this->assertSame( 8.5, $review->get_quality_score() ); + $this->assertSame( [ 'Good structure', 'Clear language' ], $review->get_strengths() ); + $this->assertEmpty( $review->get_issues() ); + } + + /** + * Test construction with rejection and issues. + */ + public function test_rejected_review_with_issues(): void { + $review = new \PRAutoBlogger_Editorial_Review( + false, + 'Content needs improvement.', + 4.0, + [], + [ 'Too short', 'Missing citations', 'Unclear conclusion' ] + ); + + $this->assertFalse( $review->is_approved() ); + $this->assertCount( 3, $review->get_issues() ); + $this->assertSame( 4.0, $review->get_quality_score() ); + } + + /** + * Test to_array completeness. + */ + public function test_to_array_returns_all_fields(): void { + $review = new \PRAutoBlogger_Editorial_Review( + true, 'OK', 7.0, [ 'Good' ], [] + ); + + $array = $review->to_array(); + $this->assertArrayHasKey( 'approved', $array ); + $this->assertArrayHasKey( 'feedback', $array ); + $this->assertArrayHasKey( 'quality_score', $array ); + $this->assertArrayHasKey( 'strengths', $array ); + $this->assertArrayHasKey( 'issues', $array ); + } +} diff --git a/tests/unit/Models/GenerationLogTest.php b/tests/unit/Models/GenerationLogTest.php new file mode 100644 index 0000000..013cdae --- /dev/null +++ b/tests/unit/Models/GenerationLogTest.php @@ -0,0 +1,93 @@ +assertSame( 'google/gemini-2.0-flash-001', $log->get_model() ); + $this->assertSame( 'content_generation', $log->get_step() ); + $this->assertSame( 500, $log->get_prompt_tokens() ); + $this->assertSame( 1200, $log->get_completion_tokens() ); + $this->assertSame( 0.0034, $log->get_cost() ); + $this->assertSame( 2.45, $log->get_duration() ); + $this->assertTrue( $log->is_success() ); + $this->assertSame( '', $log->get_error() ); + } + + /** + * Test failed generation log. + */ + public function test_failed_generation_log(): void { + $log = new \PRAutoBlogger_Generation_Log( + 'anthropic/claude-3-haiku', + 'analysis', + 200, + 0, + 0.0, + 0.0, + false, + 'Rate limit exceeded' + ); + + $this->assertFalse( $log->is_success() ); + $this->assertSame( 'Rate limit exceeded', $log->get_error() ); + $this->assertSame( 0, $log->get_completion_tokens() ); + } + + /** + * Test total tokens calculation. + */ + public function test_get_total_tokens(): void { + $log = new \PRAutoBlogger_Generation_Log( + 'model/x', 'step', 300, 700, 0.01, 1.0, true, '' + ); + + $this->assertSame( 1000, $log->get_total_tokens() ); + } + + /** + * Test to_array completeness. + */ + public function test_to_array_returns_all_fields(): void { + $log = new \PRAutoBlogger_Generation_Log( + 'model', 'step', 100, 200, 0.001, 0.5, true, '' + ); + + $array = $log->to_array(); + $this->assertArrayHasKey( 'model', $array ); + $this->assertArrayHasKey( 'step', $array ); + $this->assertArrayHasKey( 'prompt_tokens', $array ); + $this->assertArrayHasKey( 'completion_tokens', $array ); + $this->assertArrayHasKey( 'cost', $array ); + $this->assertArrayHasKey( 'duration', $array ); + $this->assertArrayHasKey( 'success', $array ); + $this->assertArrayHasKey( 'error', $array ); + } +} diff --git a/tests/unit/Models/SourceDataTest.php b/tests/unit/Models/SourceDataTest.php new file mode 100644 index 0000000..15ac17b --- /dev/null +++ b/tests/unit/Models/SourceDataTest.php @@ -0,0 +1,73 @@ +assertSame( 'Test Title', $data->get_title() ); + $this->assertSame( 'https://example.com/article', $data->get_url() ); + $this->assertSame( 'This is the content body.', $data->get_content() ); + $this->assertSame( 'rss', $data->get_source_type() ); + } + + /** + * Test that to_array returns all fields. + */ + public function test_to_array_returns_complete_representation(): void { + $data = new \PRAutoBlogger_Source_Data( + 'Array Test', + 'https://example.com', + 'Content here.', + 'manual' + ); + + $array = $data->to_array(); + + $this->assertIsArray( $array ); + $this->assertArrayHasKey( 'title', $array ); + $this->assertArrayHasKey( 'url', $array ); + $this->assertArrayHasKey( 'content', $array ); + $this->assertArrayHasKey( 'source_type', $array ); + $this->assertSame( 'Array Test', $array['title'] ); + } + + /** + * Test construction with empty content is allowed. + */ + public function test_allows_empty_content(): void { + $data = new \PRAutoBlogger_Source_Data( + 'Title Only', + 'https://example.com', + '', + 'rss' + ); + + $this->assertSame( '', $data->get_content() ); + } +} diff --git a/tests/unit/OpenRouterPricingTest.php b/tests/unit/OpenRouterPricingTest.php deleted file mode 100755 index eae9715..0000000 --- a/tests/unit/OpenRouterPricingTest.php +++ /dev/null @@ -1,147 +0,0 @@ -justReturn( '' ); - Functions\when( 'get_transient' )->justReturn( false ); - Functions\when( 'set_transient' )->justReturn( true ); - - // Mock the Logger singleton so calls don't break. - // The pricing class calls Logger::instance()->warning() for unknown models. - if ( ! class_exists( 'PRAutoBlogger_Logger' ) ) { - require_once dirname( __DIR__, 2 ) . '/includes/core/class-logger.php'; - } - - // Mock WP functions the Logger needs. - Functions\when( 'current_time' )->justReturn( '2026-04-10 12:00:00' ); - - // Mock Encryption class (used by get_api_key). - if ( ! class_exists( 'PRAutoBlogger_Encryption' ) ) { - require_once dirname( __DIR__, 2 ) . '/includes/class-encryption.php'; - } - - // Load the class under test. - if ( ! class_exists( 'PRAutoBlogger_OpenRouter_Pricing' ) ) { - require_once dirname( __DIR__, 2 ) . '/includes/providers/class-openrouter-pricing.php'; - } - } - - protected function tearDown(): void { - Monkey\tearDown(); - parent::tearDown(); - } - - public function test_known_model_cost_calculation(): void { - $pricing = new PRAutoBlogger_OpenRouter_Pricing(); - - // Claude 3.5 Haiku: $0.80/M prompt, $4.00/M completion - // 1000 prompt tokens = 1000 * 0.80 / 1_000_000 = $0.0008 - // 500 completion tokens = 500 * 4.00 / 1_000_000 = $0.002 - // Total = $0.0028 - $cost = $pricing->estimate_cost( 'anthropic/claude-3.5-haiku', 1000, 500 ); - $this->assertEqualsWithDelta( 0.0028, $cost, 0.00001 ); - } - - public function test_claude_sonnet_cost(): void { - $pricing = new PRAutoBlogger_OpenRouter_Pricing(); - - // Claude Sonnet 4: $3.00/M prompt, $15.00/M completion - // 2000 prompt + 1000 completion - // = (2000 * 3.0 / 1M) + (1000 * 15.0 / 1M) - // = 0.006 + 0.015 = 0.021 - $cost = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 2000, 1000 ); - $this->assertEqualsWithDelta( 0.021, $cost, 0.00001 ); - } - - public function test_gpt4o_mini_cost(): void { - $pricing = new PRAutoBlogger_OpenRouter_Pricing(); - - // GPT-4o-mini: $0.15/M prompt, $0.60/M completion - // 5000 prompt + 2000 completion - // = (5000 * 0.15 / 1M) + (2000 * 0.60 / 1M) - // = 0.00075 + 0.0012 = 0.00195 - $cost = $pricing->estimate_cost( 'openai/gpt-4o-mini', 5000, 2000 ); - $this->assertEqualsWithDelta( 0.00195, $cost, 0.00001 ); - } - - public function test_zero_tokens_returns_zero_cost(): void { - $pricing = new PRAutoBlogger_OpenRouter_Pricing(); - $cost = $pricing->estimate_cost( 'anthropic/claude-3.5-haiku', 0, 0 ); - $this->assertEqualsWithDelta( 0.0, $cost, 0.00001 ); - } - - public function test_unknown_model_uses_conservative_estimate(): void { - $pricing = new PRAutoBlogger_OpenRouter_Pricing(); - - // Unknown model: $10.00/M prompt, $30.00/M completion (conservative fallback) - // 1000 prompt + 1000 completion - // = (1000 * 10 / 1M) + (1000 * 30 / 1M) = 0.01 + 0.03 = 0.04 - $cost = $pricing->estimate_cost( 'unknown/fake-model', 1000, 1000 ); - $this->assertEqualsWithDelta( 0.04, $cost, 0.00001 ); - } - - public function test_cost_scales_linearly_with_tokens(): void { - $pricing = new PRAutoBlogger_OpenRouter_Pricing(); - - $cost_1x = $pricing->estimate_cost( 'anthropic/claude-3.5-haiku', 1000, 500 ); - $cost_2x = $pricing->estimate_cost( 'anthropic/claude-3.5-haiku', 2000, 1000 ); - - // 2x the tokens should be exactly 2x the cost. - $this->assertEqualsWithDelta( $cost_1x * 2, $cost_2x, 0.00001 ); - } - - public function test_prompt_and_completion_priced_separately(): void { - $pricing = new PRAutoBlogger_OpenRouter_Pricing(); - - // For a model where prompt and completion prices differ (all of them), - // swapping prompt/completion tokens should give a different cost. - $cost_a = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 1000, 500 ); - $cost_b = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 500, 1000 ); - - // Sonnet: prompt=$3/M, completion=$15/M - // A: 1000*3/1M + 500*15/1M = 0.003 + 0.0075 = 0.0105 - // B: 500*3/1M + 1000*15/1M = 0.0015 + 0.015 = 0.0165 - $this->assertEqualsWithDelta( 0.0105, $cost_a, 0.00001 ); - $this->assertEqualsWithDelta( 0.0165, $cost_b, 0.00001 ); - $this->assertNotEquals( $cost_a, $cost_b ); - } - - /** - * Verify that a realistic full-article generation stays under reasonable cost. - * This is a sanity check: if this ever fails, pricing data may be stale. - */ - public function test_full_article_cost_sanity_check(): void { - $pricing = new PRAutoBlogger_OpenRouter_Pricing(); - - // Typical multi-step article: analysis + outline + draft + polish + review - // Using Haiku for analysis, Sonnet for the rest. - $analysis_cost = $pricing->estimate_cost( 'anthropic/claude-3.5-haiku', 8000, 2000 ); - $outline_cost = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 3000, 1000 ); - $draft_cost = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 5000, 3000 ); - $polish_cost = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 6000, 3000 ); - $review_cost = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 5000, 2000 ); - - $total = $analysis_cost + $outline_cost + $draft_cost + $polish_cost + $review_cost; - - // A full article should cost between $0.01 and $1.00. - // If outside this range, pricing constants are probably wrong. - $this->assertGreaterThan( 0.01, $total, 'Full article too cheap — pricing data may be zero.' ); - $this->assertLessThan( 1.00, $total, 'Full article too expensive — pricing data may be inflated.' ); - } -} diff --git a/tests/unit/Providers/OpenRouterPricingTest.php b/tests/unit/Providers/OpenRouterPricingTest.php new file mode 100644 index 0000000..c421f75 --- /dev/null +++ b/tests/unit/Providers/OpenRouterPricingTest.php @@ -0,0 +1,114 @@ +assertNotNull( + $pricing, + "Model '{$model}' should have pricing data." + ); + $this->assertArrayHasKey( 'input', $pricing ); + $this->assertArrayHasKey( 'output', $pricing ); + $this->assertIsFloat( $pricing['input'] ); + $this->assertIsFloat( $pricing['output'] ); + } + } + + /** + * Test that unknown model returns null or fallback. + */ + public function test_unknown_model_returns_null(): void { + $pricing = \PRAutoBlogger_OpenRouter_Pricing::get_pricing( 'nonexistent/fake-model-999' ); + $this->assertNull( $pricing ); + } + + /** + * Test cost calculation produces correct result. + */ + public function test_calculate_cost_basic(): void { + // Manual calculation: if input price is $X per million tokens and output is $Y per million tokens + // cost = (prompt_tokens * X + completion_tokens * Y) / 1_000_000 + $cost = \PRAutoBlogger_OpenRouter_Pricing::calculate_cost( + 'google/gemini-2.0-flash-001', + 1000, + 2000 + ); + + $this->assertIsFloat( $cost ); + $this->assertGreaterThan( 0.0, $cost ); + } + + /** + * Test cost calculation with zero tokens returns zero. + */ + public function test_calculate_cost_zero_tokens(): void { + $cost = \PRAutoBlogger_OpenRouter_Pricing::calculate_cost( + 'google/gemini-2.0-flash-001', + 0, + 0 + ); + + $this->assertSame( 0.0, $cost ); + } + + /** + * Test cost calculation with unknown model returns zero. + */ + public function test_calculate_cost_unknown_model_returns_zero(): void { + $cost = \PRAutoBlogger_OpenRouter_Pricing::calculate_cost( + 'fake/model', + 1000, + 2000 + ); + + $this->assertSame( 0.0, $cost ); + } + + /** + * Test that all models in the pricing table have positive prices. + */ + public function test_all_model_prices_are_positive(): void { + $all_models = \PRAutoBlogger_OpenRouter_Pricing::get_all_models(); + + $this->assertNotEmpty( $all_models, 'Pricing table should not be empty.' ); + + foreach ( $all_models as $model => $pricing ) { + $this->assertGreaterThanOrEqual( + 0.0, + $pricing['input'], + "Input price for '{$model}' must be >= 0." + ); + $this->assertGreaterThanOrEqual( + 0.0, + $pricing['output'], + "Output price for '{$model}' must be >= 0." + ); + } + } +} diff --git a/tests/unit/Providers/OpenRouterProviderTest.php b/tests/unit/Providers/OpenRouterProviderTest.php new file mode 100644 index 0000000..a1095c1 --- /dev/null +++ b/tests/unit/Providers/OpenRouterProviderTest.php @@ -0,0 +1,150 @@ + [ + 'code' => 200, + ], + 'body' => wp_json_encode( [ + 'choices' => [ + [ + 'message' => [ + 'content' => 'Generated content here.', + ], + ], + ], + 'usage' => [ + 'prompt_tokens' => 150, + 'completion_tokens' => 300, + ], + ] ), + ]; + + Functions\when( 'wp_remote_post' )->justReturn( $mock_response ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( $mock_response['body'] ); + Functions\when( 'is_wp_error' )->justReturn( false ); + + $provider = new \PRAutoBlogger_OpenRouter_Provider( 'test-api-key' ); + $result = $provider->chat_completion( + 'google/gemini-2.0-flash-001', + [ + [ 'role' => 'user', 'content' => 'Write a short paragraph.' ], + ] + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'content', $result ); + $this->assertSame( 'Generated content here.', $result['content'] ); + $this->assertArrayHasKey( 'usage', $result ); + $this->assertSame( 150, $result['usage']['prompt_tokens'] ); + $this->assertSame( 300, $result['usage']['completion_tokens'] ); + } + + /** + * Test chat completion handles WP_Error (network failure). + */ + public function test_chat_completion_handles_wp_error(): void { + $wp_error = new \stdClass(); + + Functions\when( 'wp_remote_post' )->justReturn( $wp_error ); + Functions\when( 'is_wp_error' )->justReturn( true ); + + $provider = new \PRAutoBlogger_OpenRouter_Provider( 'test-api-key' ); + $result = $provider->chat_completion( + 'model/x', + [ [ 'role' => 'user', 'content' => 'test' ] ] + ); + + // Should return an error structure, not throw. + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'error', $result ); + } + + /** + * Test chat completion handles non-200 HTTP status. + */ + public function test_chat_completion_handles_http_error(): void { + $mock_response = [ + 'body' => '{"error": {"message": "Rate limit exceeded"}}', + ]; + + Functions\when( 'wp_remote_post' )->justReturn( $mock_response ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 429 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( $mock_response['body'] ); + Functions\when( 'is_wp_error' )->justReturn( false ); + + $provider = new \PRAutoBlogger_OpenRouter_Provider( 'test-api-key' ); + $result = $provider->chat_completion( + 'model/x', + [ [ 'role' => 'user', 'content' => 'test' ] ] + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'error', $result ); + } + + /** + * Test chat completion handles malformed JSON response. + */ + public function test_chat_completion_handles_malformed_json(): void { + Functions\when( 'wp_remote_post' )->justReturn( [ 'body' => 'not json' ] ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( 'not json' ); + Functions\when( 'is_wp_error' )->justReturn( false ); + + $provider = new \PRAutoBlogger_OpenRouter_Provider( 'test-api-key' ); + $result = $provider->chat_completion( + 'model/x', + [ [ 'role' => 'user', 'content' => 'test' ] ] + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'error', $result ); + } + + /** + * Test chat completion handles missing choices in response. + */ + public function test_chat_completion_handles_empty_choices(): void { + $body = wp_json_encode( [ 'choices' => [] ] ); + + Functions\when( 'wp_remote_post' )->justReturn( [ 'body' => $body ] ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body ); + Functions\when( 'is_wp_error' )->justReturn( false ); + + $provider = new \PRAutoBlogger_OpenRouter_Provider( 'test-api-key' ); + $result = $provider->chat_completion( + 'model/x', + [ [ 'role' => 'user', 'content' => 'test' ] ] + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'error', $result ); + } +}