feat: v0.2.2 — CI hardening, remove dead stubs, strengthen tests

Address CTO audit findings for PRAutoBlogger:
- CI pipeline now runs PHPCS (WordPress standards) and PHPUnit before deploy
- Removed TikTok/Instagram/YouTube stub providers (dead code, not registered)
- Rewrote PublisherTest with real behavioral assertions (post_status, metadata,
  taxonomy, log linking, hooks) instead of method-existence checks
- Added cross-system LLM budget coordination docs to ARCHITECTURE.md
- Bumped version to 0.2.2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
peptiderepo
2026-04-12 22:41:10 +07:00
parent 03074180d4
commit b21d102afb
13 changed files with 317 additions and 231 deletions

View File

@@ -6,18 +6,46 @@ on:
jobs:
validate:
name: Validate PHP Syntax
name: Validate Code Quality
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
tools: composer, phpcs
coverage: none
- name: PHP Lint Check
run: |
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -exec php -l {} \; | (grep -v "No syntax errors" || true) | head -20
test ${PIPESTATUS[0]} -eq 0
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist
- name: Install WordPress Coding Standards
run: |
composer global require "wp-coding-standards/wpcs:^3.0" --no-interaction
composer global require "phpcsstandards/phpcsutils:^1.0" --no-interaction
phpcs --config-set installed_paths "$(composer global config home)/vendor/wp-coding-standards/wpcs"
- name: Run PHPCS (WordPress standards)
run: |
phpcs --standard=WordPress-Core \
--extensions=php \
--ignore=vendor/*,tests/*,node_modules/* \
--warning-severity=0 \
--report=summary \
includes/ prautoblogger.php uninstall.php || true
- name: Run PHPUnit
run: vendor/bin/phpunit --testdox
deploy:
needs: validate
name: Deploy to Production
@@ -43,6 +71,7 @@ jobs:
--exclude 'phpunit.xml.dist' \
--exclude 'composer.json' \
--exclude 'composer.lock' \
--exclude 'vendor' \
--exclude '.distignore' \
--exclude 'ARCHITECTURE.md' \
--exclude 'CONVENTIONS.md' \

View File

@@ -130,9 +130,7 @@ prautoblogger/
│ │ ├── interface-source-provider.php # Contract for any social media source
│ │ ├── class-reddit-json-client.php # Reddit HTTP client — RSS (primary) + .json (fallback)
│ │ ├── class-reddit-provider.php # Reddit data collection orchestrator (RSS primary)
│ │ ├── class-tiktok-provider.php # TikTok — stub/future implementation
│ │ ├── class-instagram-provider.php # Instagram — stub/future implementation
│ │ └── class-youtube-provider.php # YouTube — stub/future implementation
│ │ └── (new providers go here — see CONVENTIONS.md)
│ │
│ ├── frontend/
│ │ └── class-posts-widget.php # [prautoblogger_posts] shortcode + REST endpoint
@@ -221,7 +219,7 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
| Column | Type | Description |
|---------------|-------------------|--------------------------------------------------|
| id | BIGINT UNSIGNED | Auto-increment PK |
| source_type | VARCHAR(50) | 'reddit', 'tiktok', 'instagram', 'youtube' |
| source_type | VARCHAR(50) | 'reddit' (extensible for future providers) |
| source_id | VARCHAR(255) | Platform-specific unique ID (post/comment ID) |
| subreddit | VARCHAR(255) | Subreddit or channel name (nullable) |
| title | TEXT | Post/video title |
@@ -386,7 +384,7 @@ Some users want cheap/fast, others want high quality. Making this configurable a
Rather than just tracking raw metrics, we periodically have the LLM evaluate what made high-performing posts succeed and low-performing posts fail. This feeds back into the analysis and generation prompts. Trade-off: additional API cost for scoring, but enables the self-improvement loop.
### #6: Source provider interface for future platforms
Reddit is first, but TikTok/Instagram/YouTube are planned. The interface pattern means adding a new source is one class implementation. Trade-off: slight over-engineering for day one, but pays off immediately at source #2.
Reddit is the only implemented source, but the interface pattern means adding a new source (YouTube, TikTok, etc.) is one class implementation plus a settings checkbox. We removed the old stub provider files (TikTok, Instagram, YouTube) because dead code confuses AI agents and humans alike — the interface is the contract, not empty classes. Trade-off: slight over-engineering for day one, but pays off immediately at source #2.
### #7: Custom tables for high-volume data
Source data, analysis results, and generation logs are high-write, time-series data. WordPress post_meta and options are wrong for this. Custom tables with proper indexes. Trade-off: more complex activation/uninstall, but correct.
@@ -411,3 +409,18 @@ Each pipeline execution generates a UUID (`run_id`) that tags every `prab_genera
### #14: Reddit RSS replaces PullPush.io (and earlier Reddit OAuth)
Reddit rejected our OAuth API application (April 2026). We initially switched to PullPush.io, but its index was frequently stale or unavailable. Reddit's RSS/Atom feeds (`/r/{sub}/hot.rss`) proved the most reliable option — they work from datacenter IPs where .json gets 403, require no auth, and have no apparent rate limit. The .json endpoints are kept as a fallback for posts and as the sole source for comment data. Each collected item's metadata includes a `data_source` field (`reddit_rss` or `reddit_json`) for auditability.
---
## Cross-System LLM Budget Coordination
PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets.
| Plugin | Default Models | Typical Daily Spend | Budget Control |
|--------|---------------|--------------------:|----------------|
| PRAutoBlogger | Gemini 2.5 Flash Lite (analysis + editing), Claude Sonnet 4 (writing) | $0.05–$0.30 depending on article count | Hard-stop monthly budget in plugin settings |
| Peptide News | Google Gemini 2.0 Flash (keywords + summaries) | $0.01–$0.05 | No hard budget yet (planned) |
**Important:** If your OpenRouter account has a global spending limit, set each plugin's budget to less than half the total. PRAutoBlogger will hard-stop when its budget is exhausted, but Peptide News currently has no budget enforcement — a spike in news fetches could consume shared quota.
**Future improvement:** A shared `wp_options` key (e.g., `ecosystem_monthly_llm_budget`) that both plugins read, with each plugin reserving its allocation on startup. This requires coordination at the ecosystem level and is tracked as a medium-term goal.

View File

@@ -5,6 +5,31 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/).
## [0.2.2] — 2026-04-12
### Changed
- **CI/CD pipeline now runs PHPCS and PHPUnit** before deploying.
Added `shivammathur/setup-php@v2` for PHP 8.1, Composer install,
WordPress Coding Standards check, and full PHPUnit test suite.
PHPCS runs in report-only mode (`|| true`) while codebase is
brought into full compliance; PHPUnit failures block deploy.
- Removed stub source providers (TikTok, Instagram, YouTube).
These were dead code — not registered in Source_Collector, threw
RuntimeException if enabled, and confused AI agents reading the
codebase. The `Source_Provider_Interface` remains the contract for
adding future platforms (see CONVENTIONS.md).
- Removed "coming soon" checkboxes from Enabled Sources setting.
- Updated ARCHITECTURE.md: removed stub file references, added
cross-system LLM budget coordination section documenting how
PRAutoBlogger and Peptide News share an OpenRouter account.
### Improved
- **PublisherTest rewritten with real behavioral assertions.**
Now tests: post_status ('publish' vs 'draft'), generation metadata
storage, title from idea, taxonomy assignment, tag assignment,
run_id-based log linking, RuntimeException on WP_Error, action/filter
hook firing. Replaces previous method-existence-only checks.
## [0.2.1] — 2026-04-12
### Changed

View File

@@ -186,9 +186,6 @@ class PRAutoBlogger_Settings_Fields {
'section' => 'prautoblogger_sources',
'options' => [
'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ),
'tiktok' => __( 'TikTok (coming soon)', 'prautoblogger' ),
'instagram' => __( 'Instagram (coming soon)', 'prautoblogger' ),
'youtube' => __( 'YouTube (coming soon)', 'prautoblogger' ),
],
'default' => '["reddit"]',
'description' => __( 'Select which platforms to monitor for topics.', 'prautoblogger' ),

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
/**
* Orchestrates data collection from all enabled source providers.
*
* Iterates through enabled sources (Reddit, TikTok, etc.), calls their
* Iterates through enabled sources (currently Reddit), calls their
* collect_data() method, and stores results in the ab_source_data table.
* Handles deduplication via UNIQUE index on (source_type, source_id).
*
@@ -115,7 +115,7 @@ class PRAutoBlogger_Source_Collector {
private function get_provider( string $source_type ): ?PRAutoBlogger_Source_Provider_Interface {
$providers = [
'reddit' => PRAutoBlogger_Reddit_Provider::class,
// Future providers: 'tiktok', 'instagram', 'youtube'
// New providers can be added here — see CONVENTIONS.md for how-to.
];
/**

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
/**
* Value object representing a single piece of collected social media data
* (a Reddit post, a TikTok comment, etc.).
* (e.g., a Reddit post or comment).
*
* Triggered by: Source providers create these; Content_Analyzer consumes them.
* Dependencies: None — pure data object.

View File

@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
/**
* Instagram source provider — STUB for future implementation.
*
* Will integrate with Instagram's Graph API to collect post captions,
* comments, and hashtag trends. Requires Meta developer app credentials.
*
* Triggered by: Source_Collector when 'instagram' is in enabled_sources.
* Dependencies: TBD — Instagram Graph API credentials.
*
* @see interface-source-provider.php — Interface this class implements.
*/
class PRAutoBlogger_Instagram_Provider implements PRAutoBlogger_Source_Provider_Interface {
/**
* @param array<string, mixed> $config
* @return PRAutoBlogger_Source_Data[]
* @throws \RuntimeException Always — not yet implemented.
*/
public function collect_data( array $config ): array {
throw new \RuntimeException(
__( 'Instagram source provider is not yet implemented. Remove "instagram" from enabled sources.', 'prautoblogger' )
);
}
public function get_source_type(): string {
return 'instagram';
}
public function validate_credentials(): bool {
return false;
}
public function get_rate_limit_status(): array {
return [ 'remaining' => 0, 'limit' => 0, 'resets_at' => '' ];
}
}

View File

@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
/**
* TikTok source provider — STUB for future implementation.
*
* Will integrate with TikTok's API to collect video descriptions, comments,
* and trending topics. Requires TikTok developer app credentials.
*
* Triggered by: Source_Collector when 'tiktok' is in enabled_sources.
* Dependencies: TBD — TikTok API credentials.
*
* @see interface-source-provider.php — Interface this class implements.
* @see ARCHITECTURE.md — Planned source providers.
*/
class PRAutoBlogger_Tiktok_Provider implements PRAutoBlogger_Source_Provider_Interface {
/**
* @param array<string, mixed> $config
* @return PRAutoBlogger_Source_Data[]
* @throws \RuntimeException Always — not yet implemented.
*/
public function collect_data( array $config ): array {
throw new \RuntimeException(
__( 'TikTok source provider is not yet implemented. Remove "tiktok" from enabled sources.', 'prautoblogger' )
);
}
public function get_source_type(): string {
return 'tiktok';
}
public function validate_credentials(): bool {
return false;
}
public function get_rate_limit_status(): array {
return [ 'remaining' => 0, 'limit' => 0, 'resets_at' => '' ];
}
}

View File

@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
/**
* YouTube source provider — STUB for future implementation.
*
* Will integrate with YouTube Data API v3 to collect video titles,
* descriptions, comments, and trending topics. Requires Google API key.
*
* Triggered by: Source_Collector when 'youtube' is in enabled_sources.
* Dependencies: TBD — YouTube Data API key.
*
* @see interface-source-provider.php — Interface this class implements.
*/
class PRAutoBlogger_Youtube_Provider implements PRAutoBlogger_Source_Provider_Interface {
/**
* @param array<string, mixed> $config
* @return PRAutoBlogger_Source_Data[]
* @throws \RuntimeException Always — not yet implemented.
*/
public function collect_data( array $config ): array {
throw new \RuntimeException(
__( 'YouTube source provider is not yet implemented. Remove "youtube" from enabled sources.', 'prautoblogger' )
);
}
public function get_source_type(): string {
return 'youtube';
}
public function validate_credentials(): bool {
return false;
}
public function get_rate_limit_status(): array {
return [ 'remaining' => 0, 'limit' => 0, 'resets_at' => '' ];
}
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
/**
* Contract for any social media source provider (Reddit, TikTok, Instagram, YouTube).
* Contract for any social media source provider (e.g., Reddit).
*
* Any class implementing this interface can be registered as a data source.
* The Source_Collector iterates through enabled providers and calls collect_data().

View File

@@ -8,7 +8,7 @@ declare(strict_types=1);
* Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/prautoblogger
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.2.1
* Version: 0.2.2
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.2.1' );
define( 'PRAUTOBLOGGER_VERSION', '0.2.2' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );

View File

@@ -4,7 +4,7 @@ Tags: ai, content generation, blogging, seo, automation
Requires at least: 6.0
Tested up to: 6.7
Requires PHP: 7.4
Stable tag: 0.2.0
Stable tag: 0.2.2
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

View File

@@ -2,8 +2,9 @@
/**
* Tests for PRAutoBlogger_Publisher.
*
* Validates WordPress post creation via publish() and save_as_draft() methods.
* All WordPress functions are mocked.
* Validates WordPress post creation via publish() and save_as_draft() methods,
* including correct post_status, metadata storage, taxonomy assignment, and
* generation log linking.
*
* @package PRAutoBlogger\Tests\Core
*/
@@ -12,133 +13,272 @@ namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
class PublisherTest extends BaseTestCase {
/**
* Test Publisher can be instantiated.
*/
public function test_publisher_instantiation(): void {
$publisher = new \PRAutoBlogger_Publisher();
private \PRAutoBlogger_Article_Idea $idea;
private \PRAutoBlogger_Editorial_Review $review;
$this->assertInstanceOf( \PRAutoBlogger_Publisher::class, $publisher );
}
protected function setUp(): void {
parent::setUp();
/**
* Test publish method can be called.
*/
public function test_publish_method_callable(): void {
$this->stub_wp_insert_post( 123 );
$this->idea = new \PRAutoBlogger_Article_Idea( $this->get_article_idea_fixture() );
$this->review = new \PRAutoBlogger_Editorial_Review( $this->get_editorial_review_fixture() );
$publisher = new \PRAutoBlogger_Publisher();
// Common stubs needed by Publisher.
$this->stub_get_option( [
'prautoblogger_writing_pipeline' => 'multi_step',
'prautoblogger_writing_model' => 'anthropic/claude-sonnet-4',
'prautoblogger_default_author' => 1,
] );
// Method should exist and be callable.
$this->assertTrue( method_exists( $publisher, 'publish' ) );
}
/**
* Test save_as_draft method can be called.
*/
public function test_save_as_draft_method_callable(): void {
$this->stub_wp_insert_post( 123 );
$publisher = new \PRAutoBlogger_Publisher();
// Method should exist and be callable.
$this->assertTrue( method_exists( $publisher, 'save_as_draft' ) );
}
/**
* Test publish with standard parameters.
*/
public function test_publish_with_standard_parameters(): void {
$this->stub_wp_insert_post( 456 );
$publisher = new \PRAutoBlogger_Publisher();
// Test that we can call publish. Exact behavior depends on implementation.
$this->assertTrue( method_exists( $publisher, 'publish' ) );
}
/**
* Test save_as_draft with standard parameters.
*/
public function test_save_as_draft_with_standard_parameters(): void {
$this->stub_wp_insert_post( 789 );
$publisher = new \PRAutoBlogger_Publisher();
// Test that we can call save_as_draft. Exact behavior depends on implementation.
$this->assertTrue( method_exists( $publisher, 'save_as_draft' ) );
}
/**
* Test wp_insert_post stub is functional.
*/
public function test_wp_insert_post_called_on_publish(): void {
$this->stub_wp_insert_post( 999 );
Functions\when( 'wp_slash' )->returnArg();
Functions\when( 'sanitize_text_field' )->returnArg();
Functions\when( 'wp_kses_post' )->returnArg();
Functions\when( 'is_wp_error' )->justReturn( false );
Functions\when( 'apply_filters' )->returnArg( 2 );
$publisher = new \PRAutoBlogger_Publisher();
// Verify the publisher is ready with wp_insert_post stubbed.
$this->assertInstanceOf( \PRAutoBlogger_Publisher::class, $publisher );
$this->assertTrue( method_exists( $publisher, 'publish' ) );
Functions\when( 'absint' )->alias( function ( $val ) { return abs( (int) $val ); } );
Functions\when( 'get_users' )->justReturn( [ 1 ] );
Functions\when( 'get_term_by' )->justReturn( false );
Functions\when( 'wp_insert_term' )->justReturn( [ 'term_id' => 5 ] );
Functions\when( 'wp_set_post_categories' )->justReturn( true );
Functions\when( 'wp_set_post_tags' )->justReturn( true );
}
/**
* Test wp_set_post_terms is available for taxonomy.
* Test publish() creates a post with 'publish' status and returns post ID.
*/
public function test_wp_set_post_terms_available(): void {
$this->stub_wp_set_post_terms();
public function test_publish_returns_post_id(): void {
$captured_args = null;
Functions\when( 'wp_insert_post' )->alias( function ( $args ) use ( &$captured_args ) {
$captured_args = $args;
return 42;
} );
Functions\when( 'wp_slash' )->returnArg();
Functions\when( 'is_wp_error' )->justReturn( false );
Functions\when( 'apply_filters' )->returnArg( 2 );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$post_id = $publisher->publish( '<p>Test content</p>', $this->idea, $this->review, 'run_test_123' );
// If implementation uses wp_set_post_terms, it will use the stub.
$this->assertInstanceOf( \PRAutoBlogger_Publisher::class, $publisher );
$this->assertSame( 42, $post_id );
$this->assertSame( 'publish', $captured_args['post_status'] );
$this->assertSame( 'post', $captured_args['post_type'] );
}
/**
* Test get_post stub works for post retrieval.
* Test save_as_draft() creates a post with 'draft' status.
*/
public function test_get_post_for_post_retrieval(): void {
$post_data = [
'ID' => 100,
'post_title' => 'Test Post',
'post_content' => 'Test content',
'post_status' => 'publish',
];
public function test_save_as_draft_creates_draft(): void {
$captured_args = null;
Functions\when( 'wp_insert_post' )->alias( function ( $args ) use ( &$captured_args ) {
$captured_args = $args;
return 43;
} );
$this->stub_get_post( 100, $post_data );
Functions\when( 'apply_filters' )->returnArg( 2 );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$post_id = $publisher->save_as_draft( '<p>Draft content</p>', $this->idea, $this->review );
// Verify get_post stub returns expected data.
$post = get_post( 100 );
$this->assertNotNull( $post );
$this->assertEquals( 100, $post->ID );
$this->assertSame( 43, $post_id );
$this->assertSame( 'draft', $captured_args['post_status'] );
}
/**
* Test wp_update_post is available.
* Test that generation metadata is stored in meta_input.
*/
public function test_wp_update_post_available(): void {
$this->stub_wp_update_post();
public function test_publish_stores_generation_metadata(): void {
$captured_args = null;
Functions\when( 'wp_insert_post' )->alias( function ( $args ) use ( &$captured_args ) {
$captured_args = $args;
return 44;
} );
Functions\when( 'wp_slash' )->returnArg();
Functions\when( 'is_wp_error' )->justReturn( false );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review, 'run_meta_test' );
// If implementation uses wp_update_post, the stub is ready.
$this->assertInstanceOf( \PRAutoBlogger_Publisher::class, $publisher );
$meta = $captured_args['meta_input'];
// Verify all expected meta keys exist.
$this->assertSame( '1', $meta['_prautoblogger_generated'] );
$this->assertSame( 'approved', $meta['_prautoblogger_editor_verdict'] );
$this->assertSame( 0.88, $meta['_prautoblogger_quality_score'] );
$this->assertSame( 0.92, $meta['_prautoblogger_seo_score'] );
$this->assertSame( 'anthropic/claude-sonnet-4', $meta['_prautoblogger_model_used'] );
$this->assertSame( 'multi_step', $meta['_prautoblogger_pipeline_mode'] );
$this->assertSame( 'Test Article Topic', $meta['_prautoblogger_topic'] );
$this->assertSame( 'guide', $meta['_prautoblogger_article_type'] );
}
/**
* Test that post title comes from the idea's suggested_title.
*/
public function test_publish_uses_idea_title(): void {
$captured_args = null;
Functions\when( 'wp_insert_post' )->alias( function ( $args ) use ( &$captured_args ) {
$captured_args = $args;
return 45;
} );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
$this->assertSame( 'Ultimate Guide to Test Topic', $captured_args['post_title'] );
}
/**
* Test that publish throws RuntimeException when wp_insert_post returns WP_Error.
*/
public function test_publish_throws_on_wp_error(): void {
Functions\when( 'wp_insert_post' )->justReturn( new \stdClass() );
Functions\when( 'is_wp_error' )->justReturn( true );
$wpdb = $this->create_mock_wpdb();
$GLOBALS['wpdb'] = $wpdb;
// Mock the WP_Error-like object to have get_error_message.
$error = $this->getMockBuilder( \stdClass::class )
->addMethods( [ 'get_error_message' ] )
->getMock();
$error->method( 'get_error_message' )->willReturn( 'DB insert failed' );
Functions\when( 'wp_insert_post' )->justReturn( $error );
$this->expectException( \RuntimeException::class );
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
}
/**
* Test that taxonomy terms are assigned based on article_type from the idea.
*/
public function test_publish_assigns_category_from_article_type(): void {
Functions\when( 'wp_insert_post' )->justReturn( 46 );
// get_term_by returns false (category doesn't exist), so wp_insert_term is called.
$captured_category = null;
Functions\when( 'wp_insert_term' )->alias( function ( $name, $taxonomy ) use ( &$captured_category ) {
$captured_category = $name;
return [ 'term_id' => 10 ];
} );
$captured_category_ids = null;
Functions\when( 'wp_set_post_categories' )->alias( function ( $post_id, $cat_ids ) use ( &$captured_category_ids ) {
$captured_category_ids = $cat_ids;
return true;
} );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
// Article type is 'guide' → category should be 'Guides'.
$this->assertSame( 'Guides', $captured_category );
$this->assertSame( [ 10 ], $captured_category_ids );
}
/**
* Test that target keywords are set as post tags.
*/
public function test_publish_sets_tags_from_keywords(): void {
Functions\when( 'wp_insert_post' )->justReturn( 47 );
$captured_tags = null;
Functions\when( 'wp_set_post_tags' )->alias( function ( $post_id, $tags, $append ) use ( &$captured_tags ) {
$captured_tags = $tags;
return true;
} );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
$this->assertSame( [ 'test', 'keyword', 'example' ], $captured_tags );
}
/**
* Test that generation log entries are linked via run_id.
*/
public function test_publish_links_generation_logs_by_run_id(): void {
Functions\when( 'wp_insert_post' )->justReturn( 48 );
$captured_query = null;
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'prepare' )->willReturnCallback( function ( $sql, ...$args ) use ( &$captured_query ) {
$captured_query = $sql;
return $sql;
} );
$wpdb->method( 'query' )->willReturn( 1 );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review, 'run_link_test' );
// Should use run_id-based query, not timestamp-based.
$this->assertStringContainsString( 'run_id', $captured_query );
$this->assertStringNotContainsString( 'created_at', $captured_query );
}
/**
* Test that the prautoblogger_post_created action is fired after publishing.
*/
public function test_publish_fires_post_created_action(): void {
Functions\when( 'wp_insert_post' )->justReturn( 49 );
Functions\when( 'do_action' )->alias( function () {} );
Actions\expectDone( 'prautoblogger_post_created' )
->once()
->with( 49, 'publish', $this->idea, $this->review );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
}
/**
* Test that the prautoblogger_filter_post_data filter is applied.
*/
public function test_publish_applies_post_data_filter(): void {
Functions\when( 'wp_insert_post' )->justReturn( 50 );
Filters\expectApplied( 'prautoblogger_filter_post_data' )->once();
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
}
}