diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 72790d3..56c9ac8 100755 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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' \ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a34e926..3a1caf6 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e26dce..586ed82 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/includes/admin/class-settings-fields.php b/includes/admin/class-settings-fields.php index 427e392..52e6151 100755 --- a/includes/admin/class-settings-fields.php +++ b/includes/admin/class-settings-fields.php @@ -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' ), diff --git a/includes/core/class-source-collector.php b/includes/core/class-source-collector.php index 6c7d004..410cc4a 100755 --- a/includes/core/class-source-collector.php +++ b/includes/core/class-source-collector.php @@ -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. ]; /** diff --git a/includes/models/class-source-data.php b/includes/models/class-source-data.php index 61fa407..f812e9d 100755 --- a/includes/models/class-source-data.php +++ b/includes/models/class-source-data.php @@ -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. diff --git a/includes/providers/class-instagram-provider.php b/includes/providers/class-instagram-provider.php deleted file mode 100755 index f00b5c5..0000000 --- a/includes/providers/class-instagram-provider.php +++ /dev/null @@ -1,39 +0,0 @@ - $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' => '' ]; - } -} diff --git a/includes/providers/class-tiktok-provider.php b/includes/providers/class-tiktok-provider.php deleted file mode 100755 index 90fa786..0000000 --- a/includes/providers/class-tiktok-provider.php +++ /dev/null @@ -1,40 +0,0 @@ - $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' => '' ]; - } -} diff --git a/includes/providers/class-youtube-provider.php b/includes/providers/class-youtube-provider.php deleted file mode 100755 index 42301d2..0000000 --- a/includes/providers/class-youtube-provider.php +++ /dev/null @@ -1,39 +0,0 @@ - $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' => '' ]; - } -} diff --git a/includes/providers/interface-source-provider.php b/includes/providers/interface-source-provider.php index 4c2bf14..a296af5 100755 --- a/includes/providers/interface-source-provider.php +++ b/includes/providers/interface-source-provider.php @@ -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(). diff --git a/prautoblogger.php b/prautoblogger.php index c85e55b..816ad36 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -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__ ) ); diff --git a/readme.txt b/readme.txt index f90928a..5c82d26 100755 --- a/readme.txt +++ b/readme.txt @@ -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 diff --git a/tests/unit/Core/PublisherTest.php b/tests/unit/Core/PublisherTest.php index afca559..be3801f 100644 --- a/tests/unit/Core/PublisherTest.php +++ b/tests/unit/Core/PublisherTest.php @@ -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( '
Test content
', $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( 'Draft content
', $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( 'Content
', $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( 'Content
', $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( 'Content
', $this->idea, $this->review ); + } + + /** + * Test that taxonomy terms are assigned based on article_type from the idea. + */ + public function test_publish_assigns_category_from_article_type(): void { + Functions\when( 'wp_insert_post' )->justReturn( 46 ); + + // get_term_by returns false (category doesn't exist), so wp_insert_term is called. + $captured_category = null; + Functions\when( 'wp_insert_term' )->alias( function ( $name, $taxonomy ) use ( &$captured_category ) { + $captured_category = $name; + return [ 'term_id' => 10 ]; + } ); + + $captured_category_ids = null; + Functions\when( 'wp_set_post_categories' )->alias( function ( $post_id, $cat_ids ) use ( &$captured_category_ids ) { + $captured_category_ids = $cat_ids; + return true; + } ); + + $wpdb = $this->create_mock_wpdb(); + $wpdb->method( 'query' )->willReturn( 0 ); + $wpdb->method( 'prepare' )->willReturn( '' ); + $GLOBALS['wpdb'] = $wpdb; + + $publisher = new \PRAutoBlogger_Publisher(); + $publisher->publish( 'Content
', $this->idea, $this->review ); + + // Article type is 'guide' → category should be 'Guides'. + $this->assertSame( 'Guides', $captured_category ); + $this->assertSame( [ 10 ], $captured_category_ids ); + } + + /** + * Test that target keywords are set as post tags. + */ + public function test_publish_sets_tags_from_keywords(): void { + Functions\when( 'wp_insert_post' )->justReturn( 47 ); + + $captured_tags = null; + Functions\when( 'wp_set_post_tags' )->alias( function ( $post_id, $tags, $append ) use ( &$captured_tags ) { + $captured_tags = $tags; + return true; + } ); + + $wpdb = $this->create_mock_wpdb(); + $wpdb->method( 'query' )->willReturn( 0 ); + $wpdb->method( 'prepare' )->willReturn( '' ); + $GLOBALS['wpdb'] = $wpdb; + + $publisher = new \PRAutoBlogger_Publisher(); + $publisher->publish( 'Content
', $this->idea, $this->review ); + + $this->assertSame( [ 'test', 'keyword', 'example' ], $captured_tags ); + } + + /** + * Test that generation log entries are linked via run_id. + */ + public function test_publish_links_generation_logs_by_run_id(): void { + Functions\when( 'wp_insert_post' )->justReturn( 48 ); + + $captured_query = null; + $wpdb = $this->create_mock_wpdb(); + $wpdb->method( 'prepare' )->willReturnCallback( function ( $sql, ...$args ) use ( &$captured_query ) { + $captured_query = $sql; + return $sql; + } ); + $wpdb->method( 'query' )->willReturn( 1 ); + $GLOBALS['wpdb'] = $wpdb; + + $publisher = new \PRAutoBlogger_Publisher(); + $publisher->publish( 'Content
', $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( 'Content
', $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( 'Content
', $this->idea, $this->review ); } }