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:
31
.github/workflows/deploy.yml
vendored
31
.github/workflows/deploy.yml
vendored
@@ -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' \
|
||||
|
||||
@@ -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.
|
||||
|
||||
25
CHANGELOG.md
25
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
|
||||
|
||||
@@ -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' ),
|
||||
|
||||
@@ -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.
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' => '' ];
|
||||
}
|
||||
}
|
||||
@@ -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' => '' ];
|
||||
}
|
||||
}
|
||||
@@ -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' => '' ];
|
||||
}
|
||||
}
|
||||
@@ -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().
|
||||
|
||||
@@ -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__ ) );
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user