Add unit test suite: 54 tests across models, core, and providers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_Article_Idea value object.
|
||||
*
|
||||
* Article ideas flow from the scorer to the generator to the editor —
|
||||
* data must survive the full pipeline without mutation or loss.
|
||||
*/
|
||||
class ArticleIdeaTest extends \PHPUnit\Framework\TestCase {
|
||||
|
||||
public function test_constructor_sets_all_fields(): void {
|
||||
$data = [
|
||||
'topic' => '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 );
|
||||
}
|
||||
}
|
||||
82
tests/unit/BaseTestCase.php
Normal file
82
tests/unit/BaseTestCase.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/**
|
||||
* Base test case for all PRAutoBlogger unit tests.
|
||||
*
|
||||
* Provides Brain\Monkey setup/teardown and common helpers
|
||||
* for mocking WordPress functions used across the plugin.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
abstract class BaseTestCase extends TestCase {
|
||||
|
||||
/**
|
||||
* Set up Brain\Monkey before each test.
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
// Common WordPress function stubs used across many classes.
|
||||
Functions\stubTranslationFunctions();
|
||||
Functions\stubEscapeFunctions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down Brain\Monkey after each test.
|
||||
*/
|
||||
protected function tearDown(): void {
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: stub get_option with a map of option_name => 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;
|
||||
}
|
||||
}
|
||||
166
tests/unit/Core/ContentAnalyzerTest.php
Normal file
166
tests/unit/Core/ContentAnalyzerTest.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Content_Analyzer.
|
||||
*
|
||||
* Validates the LLM analysis pipeline: prompt construction,
|
||||
* response parsing, cost tracking integration, and error handling.
|
||||
* All LLM API calls are mocked.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ContentAnalyzerTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock LLM provider.
|
||||
*/
|
||||
private $mock_provider;
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock cost tracker.
|
||||
*/
|
||||
private $mock_cost_tracker;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/models/class-prab-source-data.php';
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/models/class-prab-analysis-result.php';
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/core/class-prab-content-analyzer.php';
|
||||
|
||||
// Mock the LLM provider interface.
|
||||
$this->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 );
|
||||
}
|
||||
}
|
||||
275
tests/unit/Core/CostTrackerTest.php
Normal file
275
tests/unit/Core/CostTrackerTest.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Cost_Tracker.
|
||||
*
|
||||
* Validates API call logging, monthly spend aggregation,
|
||||
* budget enforcement, and cost estimation.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class CostTrackerTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb instance.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->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 );
|
||||
}
|
||||
}
|
||||
117
tests/unit/Core/IdeaScorerTest.php
Normal file
117
tests/unit/Core/IdeaScorerTest.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Idea_Scorer.
|
||||
*
|
||||
* Validates idea ranking, deduplication, and score normalization.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class IdeaScorerTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/models/class-prab-article-idea.php';
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/core/class-prab-idea-scorer.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Test rank_ideas returns ideas sorted by score descending.
|
||||
*/
|
||||
public function test_rank_ideas_sorts_by_score_descending(): void {
|
||||
$ideas = [
|
||||
new \PRAutoBlogger_Article_Idea( 'Low Score', 'Desc', [], 'cat', 30.0 ),
|
||||
new \PRAutoBlogger_Article_Idea( 'High Score', 'Desc', [], 'cat', 90.0 ),
|
||||
new \PRAutoBlogger_Article_Idea( 'Mid Score', 'Desc', [], 'cat', 60.0 ),
|
||||
];
|
||||
|
||||
$scorer = new \PRAutoBlogger_Idea_Scorer();
|
||||
$ranked = $scorer->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() );
|
||||
}
|
||||
}
|
||||
159
tests/unit/Core/PublisherTest.php
Normal file
159
tests/unit/Core/PublisherTest.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Publisher.
|
||||
*
|
||||
* Validates WordPress post creation with metadata,
|
||||
* generation log attachment, and error handling.
|
||||
* All WordPress functions are mocked.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class PublisherTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/core/class-prab-publisher.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Test publish creates a draft post with correct data.
|
||||
*/
|
||||
public function test_publish_creates_draft_post(): void {
|
||||
$post_data = [
|
||||
'title' => 'BPC-157: A Complete Guide',
|
||||
'content' => '<p>BPC-157 is a synthetic peptide...</p>',
|
||||
'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 <script>alert(1)</script>',
|
||||
'content' => '<p>Safe content</p>',
|
||||
'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 );
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_Editorial_Review value object.
|
||||
*
|
||||
* The editorial review verdict drives the publish/draft/reject decision.
|
||||
* A null revised_content on a "revised" verdict caused a bug (now fixed) —
|
||||
* these tests ensure the value object correctly handles edge cases.
|
||||
*/
|
||||
class EditorialReviewTest extends \PHPUnit\Framework\TestCase {
|
||||
|
||||
public function test_approved_review(): void {
|
||||
$review = new PRAutoBlogger_Editorial_Review( [
|
||||
'verdict' => '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' => '<h1>Improved Article</h1><p>Better content.</p>',
|
||||
'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() );
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_Generation_Log value object.
|
||||
*
|
||||
* Verifies construction, getters, and to_db_row() serialization.
|
||||
* These are the records that power the cost dashboard — data integrity is critical.
|
||||
*/
|
||||
class GenerationLogTest extends \PHPUnit\Framework\TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
// Mock current_time() which the constructor calls as default for created_at.
|
||||
Functions\when( 'current_time' )->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() );
|
||||
}
|
||||
}
|
||||
69
tests/unit/Models/AnalysisResultTest.php
Normal file
69
tests/unit/Models/AnalysisResultTest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Analysis_Result value object.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Models
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Models;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class AnalysisResultTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/models/class-prab-analysis-result.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Test construction with valid analysis data.
|
||||
*/
|
||||
public function test_constructor_sets_all_properties(): void {
|
||||
$result = new \PRAutoBlogger_Analysis_Result(
|
||||
'peptide-research',
|
||||
[ 'BPC-157', 'healing' ],
|
||||
8,
|
||||
'High relevance to target audience.',
|
||||
[ 'regenerative medicine', 'peptide therapy' ]
|
||||
);
|
||||
|
||||
$this->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 );
|
||||
}
|
||||
}
|
||||
73
tests/unit/Models/ArticleIdeaTest.php
Normal file
73
tests/unit/Models/ArticleIdeaTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Article_Idea value object.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Models
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Models;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class ArticleIdeaTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/models/class-prab-article-idea.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Test construction with full data.
|
||||
*/
|
||||
public function test_constructor_sets_all_properties(): void {
|
||||
$idea = new \PRAutoBlogger_Article_Idea(
|
||||
'The Future of BPC-157 Research',
|
||||
'An in-depth look at recent clinical trials.',
|
||||
[ 'BPC-157', 'clinical trials', 'peptides' ],
|
||||
'peptide-research',
|
||||
85.5
|
||||
);
|
||||
|
||||
$this->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() );
|
||||
}
|
||||
}
|
||||
70
tests/unit/Models/ContentRequestTest.php
Normal file
70
tests/unit/Models/ContentRequestTest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Content_Request value object.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Models
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Models;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class ContentRequestTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/models/class-prab-content-request.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Test construction with required fields.
|
||||
*/
|
||||
public function test_constructor_sets_all_properties(): void {
|
||||
$request = new \PRAutoBlogger_Content_Request(
|
||||
'Write about BPC-157 benefits',
|
||||
'single_pass',
|
||||
'google/gemini-2.0-flash-001',
|
||||
[ 'tone' => '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 );
|
||||
}
|
||||
}
|
||||
62
tests/unit/Models/ContentScoreTest.php
Normal file
62
tests/unit/Models/ContentScoreTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Content_Score value object.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Models
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Models;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class ContentScoreTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/models/class-prab-content-score.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Test construction with scoring dimensions.
|
||||
*/
|
||||
public function test_constructor_sets_all_dimensions(): void {
|
||||
$score = new \PRAutoBlogger_Content_Score(
|
||||
8.0, // relevance
|
||||
7.5, // quality
|
||||
9.0, // originality
|
||||
6.5, // seo
|
||||
7.75 // overall
|
||||
);
|
||||
|
||||
$this->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 );
|
||||
}
|
||||
}
|
||||
70
tests/unit/Models/EditorialReviewTest.php
Normal file
70
tests/unit/Models/EditorialReviewTest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Editorial_Review value object.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Models
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Models;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class EditorialReviewTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/models/class-prab-editorial-review.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Test construction with approval.
|
||||
*/
|
||||
public function test_approved_review(): void {
|
||||
$review = new \PRAutoBlogger_Editorial_Review(
|
||||
true,
|
||||
'Content meets quality standards.',
|
||||
8.5,
|
||||
[ 'Good structure', 'Clear language' ],
|
||||
[]
|
||||
);
|
||||
|
||||
$this->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 );
|
||||
}
|
||||
}
|
||||
93
tests/unit/Models/GenerationLogTest.php
Normal file
93
tests/unit/Models/GenerationLogTest.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Generation_Log value object.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Models
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Models;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class GenerationLogTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/models/class-prab-generation-log.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Test construction with all fields.
|
||||
*/
|
||||
public function test_constructor_sets_all_properties(): void {
|
||||
$log = new \PRAutoBlogger_Generation_Log(
|
||||
'google/gemini-2.0-flash-001',
|
||||
'content_generation',
|
||||
500,
|
||||
1200,
|
||||
0.0034,
|
||||
2.45,
|
||||
true,
|
||||
''
|
||||
);
|
||||
|
||||
$this->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 );
|
||||
}
|
||||
}
|
||||
73
tests/unit/Models/SourceDataTest.php
Normal file
73
tests/unit/Models/SourceDataTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Source_Data value object.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Models
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Models;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class SourceDataTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Require the class under test.
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/models/class-prab-source-data.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Test construction with all required fields.
|
||||
*/
|
||||
public function test_constructor_sets_all_properties(): void {
|
||||
$data = new \PRAutoBlogger_Source_Data(
|
||||
'Test Title',
|
||||
'https://example.com/article',
|
||||
'This is the content body.',
|
||||
'rss'
|
||||
);
|
||||
|
||||
$this->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() );
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_OpenRouter_Pricing cost estimation.
|
||||
*
|
||||
* Cost calculation accuracy directly affects budget enforcement and the
|
||||
* metrics dashboard. Getting this wrong means either overspending or
|
||||
* blocking generation when there's still budget left.
|
||||
*/
|
||||
class OpenRouterPricingTest extends \PHPUnit\Framework\TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
// Mock WP functions used by the pricing class.
|
||||
Functions\when( 'get_option' )->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.' );
|
||||
}
|
||||
}
|
||||
114
tests/unit/Providers/OpenRouterPricingTest.php
Normal file
114
tests/unit/Providers/OpenRouterPricingTest.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_OpenRouter_Pricing.
|
||||
*
|
||||
* Validates the model pricing table and cost calculation logic.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Providers
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Providers;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class OpenRouterPricingTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/providers/class-prab-openrouter-pricing.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that known models have pricing data.
|
||||
*/
|
||||
public function test_known_models_have_pricing(): void {
|
||||
$known_models = [
|
||||
'google/gemini-2.0-flash-001',
|
||||
'anthropic/claude-3-haiku-20240307',
|
||||
];
|
||||
|
||||
foreach ( $known_models as $model ) {
|
||||
$pricing = \PRAutoBlogger_OpenRouter_Pricing::get_pricing( $model );
|
||||
$this->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."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
150
tests/unit/Providers/OpenRouterProviderTest.php
Normal file
150
tests/unit/Providers/OpenRouterProviderTest.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_OpenRouter_Provider.
|
||||
*
|
||||
* Validates the LLM provider interface implementation:
|
||||
* request building, response parsing, error handling, and retry logic.
|
||||
* 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 OpenRouterProviderTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
require_once PRAB_PLUGIN_DIR . 'includes/providers/class-prab-openrouter-provider.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Test successful chat completion parses response correctly.
|
||||
*/
|
||||
public function test_chat_completion_parses_success_response(): void {
|
||||
$mock_response = [
|
||||
'response' => [
|
||||
'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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user