Initial commit: AutoBlogger v0.1.0

WordPress plugin that auto-researches topics from Reddit, generates
SEO-optimized articles via OpenRouter LLMs, runs them through a chief
editor agent, and publishes on a daily schedule.

Includes:
- Full generation pipeline (collect → analyze → score → generate → review → publish)
- Cost tracking and budget enforcement
- Admin UI: settings, review queue, metrics, activity log
- CI/CD pipeline (GitHub Actions → WordPress REST deploy)
- Unit test suite (PHPUnit + Brain Monkey)
- Structured logging with configurable verbosity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Terence Ong
2026-04-10 16:10:27 +07:00
commit 68c6f67bcf
65 changed files with 8833 additions and 0 deletions

65
tests/bootstrap.php Executable file
View File

@@ -0,0 +1,65 @@
<?php
/**
* PHPUnit bootstrap for AutoBlogger unit tests.
*
* Uses Brain Monkey to mock WordPress functions so tests run without
* a full WordPress installation. Only loads the classes under test.
*
* @see https://brain-wp.github.io/BrainMonkey/
*/
declare(strict_types=1);
// 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;
// Define WordPress constants that plugin code expects.
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', '/tmp/fake-wp/' );
}
if ( ! defined( 'DAY_IN_SECONDS' ) ) {
define( 'DAY_IN_SECONDS', 86400 );
}
if ( ! defined( 'HOUR_IN_SECONDS' ) ) {
define( 'HOUR_IN_SECONDS', 3600 );
}
// AutoBlogger constants from the main plugin file.
if ( ! defined( 'AUTOBLOGGER_VERSION' ) ) {
define( 'AUTOBLOGGER_VERSION', '0.1.0' );
}
if ( ! defined( 'AUTOBLOGGER_MAX_RETRIES' ) ) {
define( 'AUTOBLOGGER_MAX_RETRIES', 3 );
}
if ( ! defined( 'AUTOBLOGGER_RETRY_BASE_DELAY_SECONDS' ) ) {
define( 'AUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 2 );
}
if ( ! defined( 'AUTOBLOGGER_DEFAULT_ANALYSIS_MODEL' ) ) {
define( 'AUTOBLOGGER_DEFAULT_ANALYSIS_MODEL', 'anthropic/claude-3.5-haiku' );
}
if ( ! defined( 'AUTOBLOGGER_DEFAULT_WRITING_MODEL' ) ) {
define( 'AUTOBLOGGER_DEFAULT_WRITING_MODEL', 'anthropic/claude-sonnet-4' );
}
if ( ! defined( 'AUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) {
define( 'AUTOBLOGGER_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).

65
tests/unit/ArticleIdeaTest.php Executable file
View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* Unit tests for Autoblogger_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 Autoblogger_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 Autoblogger_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 Autoblogger_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 );
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/**
* Unit tests for Autoblogger_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 Autoblogger_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 Autoblogger_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 Autoblogger_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 Autoblogger_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 Autoblogger_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() );
}
}

128
tests/unit/GenerationLogTest.php Executable file
View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
use Brain\Monkey;
use Brain\Monkey\Functions;
/**
* Unit tests for Autoblogger_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 Autoblogger_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 Autoblogger_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 Autoblogger_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 Autoblogger_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 Autoblogger_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() );
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
use Brain\Monkey;
use Brain\Monkey\Functions;
/**
* Unit tests for Autoblogger_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( 'Autoblogger_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( 'Autoblogger_Encryption' ) ) {
require_once dirname( __DIR__, 2 ) . '/includes/class-encryption.php';
}
// Load the class under test.
if ( ! class_exists( 'Autoblogger_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 Autoblogger_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 Autoblogger_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 Autoblogger_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 Autoblogger_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 Autoblogger_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 Autoblogger_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 Autoblogger_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 Autoblogger_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.' );
}
}