From 33d3ba4ea172b89d0c996714808e89857f44d4bd Mon Sep 17 00:00:00 2001 From: peptiderepo <273788462+peptiderepo@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:48:34 +0700 Subject: [PATCH] v0.8.1: orphan research-row reaper + GA4 description fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cost): persist run_id on generated posts + back-populate for v0.8.1 reaper The orphan-research-row reaper (upcoming commit) needs a cheap way to answer "which posts belong to this run_id?" when amortizing a stranded `llm_research` row back to its siblings. Relying on the generation_log post_id column is circular: if the pipeline died before amortize_research ran, we'd trust the same table we're trying to repair. - `Publisher::build_meta()` now accepts `?string $run_id` and writes `_prautoblogger_run_id => $run_id` when non-empty. Null/empty preserves existing null-safe legacy behaviour. - `Publisher::create_post()` forwards the $run_id it already receives into build_meta — one-line plumbing, no new parameters at call sites. - `Activator::backpopulate_run_id_meta_v081()` runs once per site, gated by `prautoblogger_migrated_run_id_meta_v081` option. Walks `wp_prautoblogger_generation_log`, pairs (post_id, min(run_id)), writes `_prautoblogger_run_id` on any post that doesn't already have it. Uses `metadata_exists()` so it's a no-op on re-run. - Activator also schedules the new daily `prautoblogger_reap_orphan_research_rows` cron at 03:15 — 15 min after the primary generation cron — so live pipelines have time to finish amortizing cleanly before the reaper scans. - Deactivator unschedules the new hook alongside the existing cron hooks. No user-visible behaviour change yet. The reaper handler ships in the next commit; this one just ensures it will have the data + schedule it needs when it lands. Part of the v0.8.1 bundle (thread research-cost-reaper). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(cost): daily reaper for orphan llm_research rows Implements the orphan-research-row reaper per thread research-cost-reaper seq 1. When a pipeline dies between post creation and `Post_Assembler::amortize_research_costs()` (Hostinger exec kill, OOM, LiteSpeed cut, fatal), the shared research row stays in `wp_prautoblogger_generation_log` with `post_id = NULL` and sibling articles' cost popovers understate their true spend. New class `PRAutoBlogger_Research_Reaper` owns: - `on_cron()` — WP-Cron entrypoint hooked to the daily `prautoblogger_reap_orphan_research_rows` event (already scheduled by the Activator in the previous commit at 03:15). - `reap()` — returns a `{reaped, deleted, skipped}` summary. For each orphan `llm_research` row older than 1 hour (grace window for live pipelines): - `_prautoblogger_run_id` post_meta primary lookup. - Falls back to distinct post_ids in gen_log with the same run_id. - If ≥1 post found → delegate to the existing idempotent `Post_Assembler::amortize_research_costs()`. That method inserts the per-article rows and deletes the original orphan in one pass, so the reaper is idempotent on re-run. - If 0 posts found AND orphan is >7 days old → delete outright as sunk cost with nothing to attribute to. - Else → skip; try again the next day. - Never throws. DB errors caught and logged WARNING. New WP-CLI command `wp prautoblogger reap-research` registered in the main loader when `WP_CLI` is defined — manual ops trigger that prints the same summary. Departure from the CTO's spec: the reap logic lives on `Research_Reaper` instead of `Post_Assembler`. Placing it on Post_Assembler pushed that file to 391 lines, over the 300-line cap. Moving it to the thin class colocates the attribution logic with the cron/CLI plumbing that triggers it. `amortize_research_costs()` stays on Post_Assembler — the reaper delegates to it unchanged. Flagged for CTO review. Part of the v0.8.1 bundle (thread research-cost-reaper). Co-Authored-By: Claude Opus 4.7 (1M context) * test(cost): ResearchReaperTest — five cases per spec Covers the five scenarios from convo thread research-cost-reaper seq 1: - test_reaps_orphan_with_matching_run_id_posts — meta lookup returns two post IDs; reaper delegates to amortize_research_costs which inserts two rows and deletes the orphan. - test_skips_orphan_within_grace_period — the SELECT's `created_at < now - 1h` clause filters fresh orphans at the DB; the reaper's stats stay zero and no side effects fire. - test_deletes_orphan_with_no_articles_after_7_days — orphan is 8 days old, zero siblings via meta or gen_log; reaper deletes outright. - test_falls_back_to_gen_log_when_post_meta_missing — meta returns empty (legacy pre-v0.8.1 post); gen_log has two distinct post_ids; amortize still runs. - test_idempotent_on_repeat_run — second pass on a cleaned state returns 0/0/0 with no inserts or deletes. All DB interaction mocked via $wpdb stub — no real DB round-trips. Fixtures are driven by property mutations in the test methods so PHPUnit's "one stub per method" restriction on mocks doesn't bite. Part of the v0.8.1 bundle. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(v0.8.1): version bump + CHANGELOG + ARCHITECTURE.md for reaper Bump plugin header and PRAUTOBLOGGER_VERSION 0.8.0 → 0.8.1. Patch bump — the reaper is internal infrastructure; no user-visible UX change. CHANGELOG: - Fixed: orphan `llm_research` rows now reaped daily. - Added: WP-CLI `wp prautoblogger reap-research`; `_prautoblogger_run_id` post_meta is now written by Publisher on every post. ARCHITECTURE.md: - Decision #13 (Run-ID based log linking) cross-refs the new post_meta. - New Decision #19 documents the reaper's scan/amortize/delete policy, the 1-hour grace window, the 7-day stale cutoff, and the one-shot activator back-populate migration. DB schema unchanged — no PRAUTOBLOGGER_DB_VERSION bump. Final commit of the v0.8.1 bundle. Co-Authored-By: Claude Opus 4.7 (1M context) * test(cost): widen $wpdb mock so ResearchReaperTest actually runs BaseTestCase::create_mock_wpdb() only adds methods [prepare, get_var, get_results, insert, query, get_row, update] — the reaper and amortize_research_costs also call `delete` and `get_col`, which the default mock can't stub. CI on PR #100 fired all five new ResearchReaperTest cases as errors at the first `->method('delete')` call during setUp. Fix: build a bespoke $wpdb mock in this file's setUp with the full superset of methods it needs. Self-contained; no change to BaseTestCase so existing tests aren't affected. Co-Authored-By: Claude Opus 4.7 (1M context) * test(cost): filter gen_log inserts from event_log inserts in reaper tests CI on ec1efee showed the two reap-and-amortize tests failing because `assertCount(2, $this->inserted_rows)` was seeing 4 rows, not 2. Root cause: the Logger singleton writes INFO lines via `$wpdb->insert` into the event_log table — the reaper logs once ("Reaped...") and amortize logs once ("Amortized LLM research cost...") before the two per-post gen_log inserts, so the capture ended up with 4 total rows. Add a `gen_log_inserts()` helper that filters the capture down to rows whose `__table` contains "generation_log" — the assertions on attribution count should only measure the actual cost inserts, not incidental log writes. Two tests updated; logic unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(admin): correct misleading GA4 Property ID description The GA4 client at includes/core/class-ga4-client.php:85 already wraps the property_id option with "properties/" in the outbound URL path. The admin description told users to enter "properties/XXXXXXXXX" — which would have produced a double-prefixed URL (properties/properties/XXXXXXXXX) and a guaranteed 404 from the Data API. Description now asks for digits only, matching actual code behavior. No code logic change — UX copy + CHANGELOG only. Flagged by CEO while inspecting the Analytics tab; folded into the in-flight v0.8.1 PR per CTO call rather than cutting a separate patch. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: peptiderepo Co-authored-by: Claude Opus 4.7 (1M context) --- ARCHITECTURE.md | 5 +- CHANGELOG.md | 26 ++ .../admin/class-settings-fields-extended.php | 2 +- includes/class-activator.php | 58 ++++ includes/class-deactivator.php | 1 + includes/class-prautoblogger.php | 18 ++ includes/core/class-publisher.php | 17 +- includes/core/class-research-reaper.php | 183 +++++++++++ prautoblogger.php | 4 +- tests/unit/Core/ResearchReaperTest.php | 286 ++++++++++++++++++ 10 files changed, 593 insertions(+), 7 deletions(-) create mode 100644 includes/core/class-research-reaper.php create mode 100644 tests/unit/Core/ResearchReaperTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f2f8951..b04ec15 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -521,7 +521,10 @@ The posts widget uses WordPress-bundled React (`wp.element`) with raw `createEle The widget fetches posts asynchronously from a dedicated REST endpoint rather than rendering server-side. This keeps initial page load fast and allows the endpoint to be cached independently (5-minute Cache-Control). The permission callback uses a filter (`prautoblogger_rest_posts_public`) so sites can restrict access if needed. ### #13: Run-ID based log linking -Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession. +Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession. **v0.8.1+**: `run_id` is also persisted on each post as `_prautoblogger_run_id` post_meta, giving the orphan-research reaper a direct path from run to siblings without walking the gen_log table (see #19). + +### #19: Daily orphan-research-row reaper (v0.8.1) +When a pipeline dies between post creation and the final `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill, OOM, LiteSpeed cut, fatal), the shared `llm_research` cost row stays in `prab_generation_log` with `post_id = NULL` and sibling articles understate their true cost in the breakdown popover. A new daily WP-Cron event `prautoblogger_reap_orphan_research_rows` (hooked to `PRAutoBlogger_Research_Reaper::on_cron`, 03:15 server time — 15 min after the primary generation cron) scans for orphan rows older than a 1-hour grace window, looks up sibling posts by `_prautoblogger_run_id` post_meta (fallback: distinct `post_id`s in gen_log for the same run_id), and delegates to the existing idempotent `amortize_research_costs()` on match. Orphans that still have no siblings after 7 days are deleted outright as sunk cost. A one-time activator migration (`prautoblogger_migrated_run_id_meta_v081`) back-populates `_prautoblogger_run_id` on existing posts from gen_log so the meta-primary path works on upgrade. Manual ops trigger: `wp prautoblogger reap-research`. ### #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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac782e..28afb2c 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ 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.8.1] — 2026-04-21 + +### Fixed + +- **Orphaned LLM research costs now reaped daily.** When a pipeline died + between post creation and the final cost-amortization step (e.g. + execution-time kill on shared hosting), the `llm_research` row stayed + unlinked and the affected articles' cost popovers understated true spend. + A new daily cron (`prautoblogger_reap_orphan_research_rows`, 03:15 + server time) retroactively amortizes orphan rows against posts from + the same run. Includes a one-time activator migration to back-populate + `_prautoblogger_run_id` post_meta for existing posts. +- **GA4 Property ID admin description corrected.** The field description + previously instructed users to enter `properties/XXXXXXXXX`, but the + GA4 client already prepends the `properties/` URL segment internally — + a double-prefixed value would 404. Description now asks for the digits + only, matching actual code behavior. + +### Added + +- New WP-CLI command `wp prautoblogger reap-research` for manual reaping + and ops debugging. +- `Publisher::build_meta()` now writes `_prautoblogger_run_id` on every + generated post, so the reaper has a direct meta path from run_id to + sibling posts without walking `wp_prautoblogger_generation_log`. + ## [0.8.0] — 2026-04-21 ### Changed diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index 8431457..673a21b 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -154,7 +154,7 @@ class PRAutoBlogger_Settings_Fields_Extended { 'label' => __( 'GA4 Property ID', 'prautoblogger' ), 'type' => 'text', 'section' => 'prautoblogger_analytics', - 'description' => __( 'Format: properties/XXXXXXXXX. Leave blank to skip GA4.', 'prautoblogger' ), + 'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ), ], [ 'id' => 'prautoblogger_ga4_credentials_json', diff --git a/includes/class-activator.php b/includes/class-activator.php index cd5103e..0177026 100755 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -28,6 +28,7 @@ class PRAutoBlogger_Activator { self::create_tables(); self::set_default_options(); self::schedule_cron(); + self::backpopulate_run_id_meta_v081(); // Store the DB version so we can run migrations on future updates. update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false ); @@ -225,5 +226,62 @@ class PRAutoBlogger_Activator { if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' ); } + + // v0.8.1: daily reaper for orphan `llm_research` rows. Pinned to + // 03:15 so it runs 15 min after the primary generation cron, giving + // any live pipeline time to finish and call amortize_research_costs + // cleanly before the reaper scans. + if ( ! wp_next_scheduled( 'prautoblogger_reap_orphan_research_rows' ) ) { + $tomorrow = strtotime( 'tomorrow 03:15' ); + if ( false !== $tomorrow ) { + wp_schedule_event( $tomorrow, 'daily', 'prautoblogger_reap_orphan_research_rows' ); + } + } + } + + /** + * One-shot v0.8.1 migration — back-populate `_prautoblogger_run_id` + * post_meta for existing posts from their gen-log rows. + * + * Lets the orphan-research-row reaper (v0.8.1+) attribute historic + * orphan rows to sibling posts via post_meta lookup, without having + * to walk the generation_log table. Gated by + * `prautoblogger_migrated_run_id_meta_v081` so it runs exactly once + * per site. + * + * Side effects: adds post_meta rows for existing PRAutoBlogger posts + * that don't already have `_prautoblogger_run_id`. + * + * @return void + */ + private static function backpopulate_run_id_meta_v081(): void { + if ( get_option( 'prautoblogger_migrated_run_id_meta_v081' ) ) { + return; + } + + global $wpdb; + $gen_log = $wpdb->prefix . 'prautoblogger_generation_log'; + + // For each (post_id, run_id) pair present in the gen-log, write + // the run_id meta if it's not already set. Uses the first run_id + // seen per post — in practice each post is linked to exactly one run. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $pairs = $wpdb->get_results( + "SELECT post_id, MIN(run_id) AS run_id FROM {$gen_log} + WHERE post_id IS NOT NULL AND run_id IS NOT NULL AND run_id != '' + GROUP BY post_id" + ); + + if ( is_array( $pairs ) ) { + foreach ( $pairs as $row ) { + $post_id = (int) ( $row->post_id ?? 0 ); + $run_id = (string) ( $row->run_id ?? '' ); + if ( $post_id > 0 && '' !== $run_id && ! metadata_exists( 'post', $post_id, '_prautoblogger_run_id' ) ) { + update_post_meta( $post_id, '_prautoblogger_run_id', $run_id ); + } + } + } + + update_option( 'prautoblogger_migrated_run_id_meta_v081', '1' ); } } diff --git a/includes/class-deactivator.php b/includes/class-deactivator.php index be1158c..a1d9405 100755 --- a/includes/class-deactivator.php +++ b/includes/class-deactivator.php @@ -36,6 +36,7 @@ class PRAutoBlogger_Deactivator { $hooks = [ 'prautoblogger_daily_generation', 'prautoblogger_collect_metrics', + 'prautoblogger_reap_orphan_research_rows', ]; foreach ( $hooks as $hook ) { diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 2a26c16..7441c58 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -116,6 +116,24 @@ class PRAutoBlogger { // Single-idea generation from the Ideas browser page. add_action( 'prautoblogger_generate_from_idea', [ 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ] ); + + // v0.8.1: daily reaper for orphan `llm_research` rows (when a + // pipeline dies before amortize_research_costs runs). + add_action( 'prautoblogger_reap_orphan_research_rows', [ 'PRAutoBlogger_Research_Reaper', 'on_cron' ] ); + + // v0.8.1: WP-CLI manual trigger for the reaper. Only registers when + // WP-CLI is present; no-op in normal HTTP requests. + if ( defined( 'WP_CLI' ) && WP_CLI ) { + \WP_CLI::add_command( 'prautoblogger reap-research', static function (): void { + $stats = PRAutoBlogger_Research_Reaper::reap(); + \WP_CLI::success( sprintf( + 'Reaped %d, deleted %d (stale), skipped %d.', + (int) $stats['reaped'], + (int) $stats['deleted'], + (int) $stats['skipped'] + ) ); + } ); + } } /** Register AJAX handlers for admin actions. */ diff --git a/includes/core/class-publisher.php b/includes/core/class-publisher.php index 879f812..74f7119 100755 --- a/includes/core/class-publisher.php +++ b/includes/core/class-publisher.php @@ -87,7 +87,7 @@ class PRAutoBlogger_Publisher { 'post_status' => $post_status, 'post_type' => 'post', 'post_author' => PRAutoBlogger_Post_Assembler::get_default_author_id(), - 'meta_input' => $this->build_meta( $idea, $review ), + 'meta_input' => $this->build_meta( $idea, $review, $run_id ), ]; /** @see class-prautoblogger.php — listeners registered in main loader. */ @@ -120,15 +120,22 @@ class PRAutoBlogger_Publisher { /** * Build post_meta array for generation metadata. * + * The `_prautoblogger_run_id` key (added v0.8.1) lets the orphan-research + * reaper attribute an orphan `llm_research` row back to its sibling + * articles without re-walking the gen_log table. See + * core/class-research-reaper.php. + * * @param PRAutoBlogger_Article_Idea $idea * @param PRAutoBlogger_Editorial_Review $review + * @param string|null $run_id Pipeline run UUID, or null in legacy paths. * @return array */ private function build_meta( PRAutoBlogger_Article_Idea $idea, - PRAutoBlogger_Editorial_Review $review + PRAutoBlogger_Editorial_Review $review, + ?string $run_id = null ): array { - return [ + $meta = [ '_prautoblogger_generated' => '1', '_prautoblogger_analysis_id' => $idea->get_analysis_id(), '_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ), @@ -143,5 +150,9 @@ class PRAutoBlogger_Publisher { '_prautoblogger_article_type' => $idea->get_article_type(), '_prautoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ), ]; + if ( null !== $run_id && '' !== $run_id ) { + $meta['_prautoblogger_run_id'] = $run_id; + } + return $meta; } } diff --git a/includes/core/class-research-reaper.php b/includes/core/class-research-reaper.php new file mode 100644 index 0000000..28ff000 --- /dev/null +++ b/includes/core/class-research-reaper.php @@ -0,0 +1,183 @@ + 0, 'deleted' => 0, 'skipped' => 0 ]; + + try { + global $wpdb; + $log_table = $wpdb->prefix . 'prautoblogger_generation_log'; + $grace = gmdate( 'Y-m-d H:i:s', time() - self::GRACE_WINDOW_SECONDS ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $orphans = $wpdb->get_results( + $wpdb->prepare( + "SELECT id, run_id, created_at FROM {$log_table} + WHERE stage = 'llm_research' AND post_id IS NULL AND created_at < %s", + $grace + ) + ); + if ( ! is_array( $orphans ) ) { + return $stats; + } + + $stale_cutoff = time() - self::STALE_WINDOW_SECONDS; + foreach ( $orphans as $orphan ) { + self::handle_orphan( $orphan, $stale_cutoff, $log_table, $stats ); + } + } catch ( \Throwable $e ) { + PRAutoBlogger_Logger::instance()->warning( + 'Reaper encountered DB error: ' . $e->getMessage(), + 'cost-tracker' + ); + } + + return $stats; + } + + /** + * Route one orphan row: amortize if siblings exist, delete if stale, + * otherwise skip and try again next run. + * + * @param object $orphan DB row with id, run_id, created_at. + * @param int $stale_cutoff Unix timestamp — orphans older than this delete. + * @param string $log_table Fully-qualified gen_log table name. + * @param array $stats Counters, mutated by reference. + * @return void + */ + private static function handle_orphan( object $orphan, int $stale_cutoff, string $log_table, array &$stats ): void { + global $wpdb; + $run_id = (string) ( $orphan->run_id ?? '' ); + $created_ts = strtotime( (string) ( $orphan->created_at ?? '' ) ); + $is_stale = false !== $created_ts && $created_ts < $stale_cutoff; + + if ( '' === $run_id ) { + if ( $is_stale ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] ); + $stats['deleted']++; + } else { + $stats['skipped']++; + } + return; + } + + $post_ids = self::find_posts_for_run_id( $run_id ); + if ( count( $post_ids ) >= 1 ) { + PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id ); + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Reaped research cost across %d articles for run %s.', count( $post_ids ), $run_id ), + 'cost-tracker' + ); + $stats['reaped']++; + return; + } + + if ( $is_stale ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] ); + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Deleted 7-day-stale orphan research row for run %s — no articles to attribute.', $run_id ), + 'cost-tracker' + ); + $stats['deleted']++; + return; + } + + $stats['skipped']++; + } + + /** + * Return distinct post IDs for a given run_id. Prefers post_meta; + * falls back to gen_log rows for legacy posts. + * + * @param string $run_id Pipeline run UUID. + * @return int[] Distinct post IDs. + */ + private static function find_posts_for_run_id( string $run_id ): array { + $meta_ids = get_posts( [ + 'post_type' => 'any', + 'post_status' => 'any', + 'meta_key' => '_prautoblogger_run_id', + 'meta_value' => $run_id, + 'fields' => 'ids', + 'posts_per_page' => -1, + 'suppress_filters' => true, + ] ); + if ( is_array( $meta_ids ) && ! empty( $meta_ids ) ) { + return array_map( 'intval', $meta_ids ); + } + + global $wpdb; + $log_table = $wpdb->prefix . 'prautoblogger_generation_log'; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT DISTINCT post_id FROM {$log_table} WHERE run_id = %s AND post_id IS NOT NULL", + $run_id + ) + ); + return is_array( $ids ) ? array_map( 'intval', $ids ) : []; + } +} diff --git a/prautoblogger.php b/prautoblogger.php index 6a5222a..7293201 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.8.0 + * Version: 0.8.1 * 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.8.0' ); +define( 'PRAUTOBLOGGER_VERSION', '0.8.1' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/tests/unit/Core/ResearchReaperTest.php b/tests/unit/Core/ResearchReaperTest.php new file mode 100644 index 0000000..e97a973 --- /dev/null +++ b/tests/unit/Core/ResearchReaperTest.php @@ -0,0 +1,286 @@ +insert). + * + * @var array> + */ + private array $inserted_rows = []; + + /** + * Captured DELETE calls: [$table, $where-array]. + * + * @var array}> + */ + private array $deleted_rows = []; + + /** + * Fixture: the rows get_results should hand back for the orphan scan. + * + * @var array + */ + private array $orphan_fixtures = []; + + /** + * Fixture: post IDs the reaper's fallback gen_log query should return. + * + * @var int[] + */ + private array $gen_log_post_ids = []; + + protected function setUp(): void { + parent::setUp(); + + $this->inserted_rows = []; + $this->deleted_rows = []; + $this->orphan_fixtures = []; + $this->gen_log_post_ids = []; + + // Bespoke $wpdb mock — BaseTestCase::create_mock_wpdb() doesn't + // stub `delete` or `get_col`, both of which the reaper + amortize + // call into. Wide-method mock keeps the test scope self-contained. + $this->wpdb = $this->getMockBuilder( \stdClass::class ) + ->addMethods( [ 'prepare', 'get_var', 'get_results', 'get_row', 'get_col', 'insert', 'delete', 'update', 'query' ] ) + ->getMock(); + $this->wpdb->prefix = 'wp_'; + $this->wpdb->insert_id = 1; + $this->wpdb->last_error = ''; + $this->wpdb->prab_cost_logs = 'wp_prab_cost_logs'; + $this->wpdb->method( 'insert' )->willReturnCallback( + function ( $table, $row ) { + $this->inserted_rows[] = array_merge( [ '__table' => $table ], $row ); + return 1; + } + ); + $this->wpdb->method( 'delete' )->willReturnCallback( + function ( $table, $where ) { + $this->deleted_rows[] = [ (string) $table, (array) $where ]; + return 1; + } + ); + // prepare() returns its args joined so we can pattern-match in get_* stubs. + $this->wpdb->method( 'prepare' )->willReturnCallback( + function ( $sql, ...$args ) { + return $sql . '|' . implode( '|', array_map( 'strval', $args ) ); + } + ); + // get_results routes by SQL fragment between the reaper's orphan scan + // and the occasional Logger insert probes. + $this->wpdb->method( 'get_results' )->willReturnCallback( + function ( $sql ) { + if ( false !== stripos( (string) $sql, "post_id IS NULL AND created_at <" ) ) { + return $this->orphan_fixtures; + } + return []; + } + ); + // get_row serves amortize_research_costs's research-row lookup. + $this->wpdb->method( 'get_row' )->willReturnCallback( + function ( $sql ) { + foreach ( $this->orphan_fixtures as $o ) { + if ( false !== stripos( (string) $sql, (string) $o->run_id ) ) { + return (object) [ + 'id' => $o->id, + 'estimated_cost' => $o->estimated_cost ?? 0.50, + 'provider' => $o->provider ?? 'openrouter', + 'model' => $o->model ?? 'gemini-flash', + 'prompt_tokens' => $o->prompt_tokens ?? 1000, + 'completion_tokens' => $o->completion_tokens ?? 500, + ]; + } + } + return null; + } + ); + // get_col is the reaper's fallback "posts by run_id" and amortize's + // "distinct post_ids" query — both read from the same fixture. + $this->wpdb->method( 'get_col' )->willReturnCallback( + fn() => array_map( 'strval', $this->gen_log_post_ids ) + ); + + $GLOBALS['wpdb'] = $this->wpdb; + + $this->stub_get_option( [ + 'prautoblogger_log_level' => 'info', + ] ); + + // Reaper uses get_posts to look up sibling posts via post_meta. + Functions\when( 'get_posts' )->justReturn( [] ); + } + + protected function tearDown(): void { + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** + * Return only the `generation_log` inserts from the capture — filters out + * the `event_log` rows that Logger::instance()->info() writes as a side + * effect of the reaper + amortize's own progress logging. + * + * @return array> + */ + private function gen_log_inserts(): array { + return array_values( array_filter( + $this->inserted_rows, + static fn( $row ) => false !== strpos( (string) ( $row['__table'] ?? '' ), 'generation_log' ) + ) ); + } + + /** + * Helper: seed the orphan list returned by the reaper's primary SELECT. + * + * @param array $orphans + */ + private function seed_orphans( array $orphans ): void { + $this->orphan_fixtures = array_map( + static fn( $r ) => (object) $r, + $orphans + ); + } + + /** + * Helper: set the post_ids the gen_log fallback query returns. + * + * @param int[] $post_ids + */ + private function seed_gen_log_posts_for_run( array $post_ids ): void { + $this->gen_log_post_ids = $post_ids; + } + + /** + * 1. Reap succeeds via post_meta primary lookup. + * + * Orphan row is >1h old, `_prautoblogger_run_id` meta returns two + * post IDs, amortize inserts two attributed rows and deletes the + * original orphan. + */ + public function test_reaps_orphan_with_matching_run_id_posts(): void { + $old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) ); + $this->seed_orphans( [ [ 'id' => 99, 'run_id' => 'run-abc', 'created_at' => $old_ts, 'estimated_cost' => 0.40 ] ] ); + + // In the v0.8.1 world both sources agree. Meta is the reaper's gate; + // gen_log also has rows so `amortize_research_costs()` can do its + // own internal post-count query. + Functions\when( 'get_posts' )->alias( + static fn( $args ) => ( '_prautoblogger_run_id' === ( $args['meta_key'] ?? '' ) && 'run-abc' === ( $args['meta_value'] ?? '' ) ) + ? [ 101, 102 ] + : [] + ); + $this->seed_gen_log_posts_for_run( [ 101, 102 ] ); + + $stats = \PRAutoBlogger_Research_Reaper::reap(); + + $this->assertSame( 1, $stats['reaped'] ); + $this->assertSame( 0, $stats['deleted'] ); + $this->assertSame( 0, $stats['skipped'] ); + $this->assertCount( + 2, + $this->gen_log_inserts(), + 'One amortized row per post should be inserted into the generation_log.' + ); + // Original orphan deleted by amortize_research_costs. + $this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 99 ) ); + } + + /** + * 2. Orphan within the 1-hour grace window is left alone — a live + * pipeline may still be in the middle of its own amortize step. + */ + public function test_skips_orphan_within_grace_period(): void { + // The primary SELECT filters on `created_at < (now - 1h)`, so a + // fresh orphan is excluded from the result set at the DB boundary. + // We simulate that by returning an empty orphan list. + $this->seed_orphans( [] ); + + Functions\when( 'get_posts' )->justReturn( [ 101, 102 ] ); + + $stats = \PRAutoBlogger_Research_Reaper::reap(); + + $this->assertSame( 0, $stats['reaped'] ); + $this->assertSame( 0, $stats['deleted'] ); + $this->assertSame( 0, $stats['skipped'] ); + $this->assertEmpty( $this->inserted_rows ); + $this->assertEmpty( $this->deleted_rows ); + } + + /** + * 3. Orphan older than 7 days with zero matching posts is deleted + * outright — research cost is sunk with nothing to attribute to. + */ + public function test_deletes_orphan_with_no_articles_after_7_days(): void { + $stale_ts = gmdate( 'Y-m-d H:i:s', time() - ( 8 * DAY_IN_SECONDS ) ); + $this->seed_orphans( [ [ 'id' => 77, 'run_id' => 'run-dead', 'created_at' => $stale_ts ] ] ); + + // No posts via meta, no posts via gen_log. + Functions\when( 'get_posts' )->justReturn( [] ); + $this->seed_gen_log_posts_for_run( [] ); + + $stats = \PRAutoBlogger_Research_Reaper::reap(); + + $this->assertSame( 0, $stats['reaped'] ); + $this->assertSame( 1, $stats['deleted'] ); + $this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 77 ) ); + } + + /** + * 4. When `_prautoblogger_run_id` post_meta is absent (legacy posts + * predating v0.8.1), the reaper falls back to querying gen_log for + * distinct post_ids carrying the same run_id. + */ + public function test_falls_back_to_gen_log_when_post_meta_missing(): void { + $old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) ); + $this->seed_orphans( [ [ 'id' => 55, 'run_id' => 'run-legacy', 'created_at' => $old_ts, 'estimated_cost' => 0.30 ] ] ); + + // Meta lookup returns nothing. + Functions\when( 'get_posts' )->justReturn( [] ); + // But gen_log has two posts with this run_id. + $this->seed_gen_log_posts_for_run( [ 201, 202 ] ); + + $stats = \PRAutoBlogger_Research_Reaper::reap(); + + $this->assertSame( 1, $stats['reaped'] ); + $this->assertCount( 2, $this->gen_log_inserts() ); + } + + /** + * 5. A second reap run is a no-op — the first run deleted the orphan + * and inserted amortized per-article rows, so the scan returns empty. + */ + public function test_idempotent_on_repeat_run(): void { + // Simulate "already reaped" state: scan returns no orphans. + $this->seed_orphans( [] ); + + $stats_1 = \PRAutoBlogger_Research_Reaper::reap(); + $stats_2 = \PRAutoBlogger_Research_Reaper::reap(); + + $this->assertSame( 0, $stats_1['reaped'] ); + $this->assertSame( 0, $stats_2['reaped'] ); + $this->assertEmpty( $this->inserted_rows ); + $this->assertEmpty( $this->deleted_rows ); + } +}