diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ba80f86..b86110c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -860,11 +860,11 @@ Tests use Brain\Monkey for WordPress function mocking (no database required). Th -### #22b: Kanban board as primary admin landing screen (v0.19.0) +### #22b: Kanban board as primary admin landing screen (v0.19.0 / v0.19.1) The primary admin screen is now a kanban board (Direction C "Editorial Record") with four columns: Generating | In Review | Published | Failed. A separate `class-board-page.php` -registers the `Board` submenu first so it appears before Settings in the nav. +registers the `Board` submenu and a `$submenu` reorder makes it the first (primary) entry. `class-board-data-provider.php` orchestrates the four columns and handles WP_Query columns (In Review, Published). Raw `prab_generation_log` queries (Generating, Failed) are in the extracted `class-board-gen-log-query.php` (split for 300-line compliance) -- no new schema @@ -875,6 +875,18 @@ fallback. M2 will rename Runs & Audit -> Articles and wire board cards to the Ar Poll interval and published window are settings-backed, localized into board.js, never hardcoded. +**Menu-ordering constraint (v0.19.1 hotfix):** `PRAutoBlogger_Board_Page::on_register_menu` +MUST be hooked at `admin_menu` priority 11 (or higher), AFTER +`PRAutoBlogger_Admin_Page::on_register_menu` which runs at priority 10. Reason: WordPress +computes the page hookname via `get_plugin_page_hookname()` at `add_submenu_page()` call +time. If `add_menu_page()` has not yet run, `$admin_page_hooks['prautoblogger-settings']` +is unset and WordPress falls back to the `admin_page_*` hookname. At HTTP request time WP +recomputes the hookname as `prautoblogger_page_prautoblogger-board` and finds no registered +callback -> `wp_die("Invalid plugin page")` 404. The enqueue-gate in `on_enqueue_assets()` +checks `prautoblogger_page_prautoblogger-board` and would similarly never fire with the +wrong hookname (board.css/board.js would silently not load). Regression test: +`tests/unit/Admin/BoardMenuRegistrationTest.php`. + ## 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4781e..a46d12b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ 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.19.1] - 2026-06-12 + +### Fixed +- **Board admin submenu 404 (P0 hotfix).** `PRAutoBlogger_Board_Page::on_register_menu` + was hooked at `admin_menu` priority 10, identical to + `PRAutoBlogger_Admin_Page::on_register_menu`. Because the board hook was + registered first in `register_admin_hooks()`, `add_submenu_page()` fired while + `$admin_page_hooks['prautoblogger-settings']` was still unset. WordPress fell back + to the `admin_page_prautoblogger-board` hookname; at request time it recomputed + `prautoblogger_page_prautoblogger-board`, found no registered callback, and called + `wp_die()` with "Invalid plugin page". Board CSS/JS also never loaded (same + enqueue-gate mismatch). Fix: parent menu hook stays at priority 10; board submenu + hook moves to priority 11, guaranteeing `add_menu_page()` fires first. Board is + kept as the first submenu item via explicit `$submenu` reorder in + `PRAutoBlogger_Board_Page::on_register_menu()`. +- **Regression test added:** `tests/unit/Admin/BoardMenuRegistrationTest.php` — + three assertions covering priority relationship, execution order, and parent-slug + correctness. Includes a "documents the bug" test that fires the unfixed hook order + to prove the broken state. + ## [0.19.0] - 2026-06-12 ### Added diff --git a/includes/admin/class-board-page.php b/includes/admin/class-board-page.php index 69d0864..02ac089 100644 --- a/includes/admin/class-board-page.php +++ b/includes/admin/class-board-page.php @@ -42,6 +42,14 @@ class PRAutoBlogger_Board_Page { * submenu duplicate of the top-level page; we name it "Board" so the * first click goes to the board, not the raw settings page. * + * Registration order note: this method is hooked at admin_menu priority 11 + * so that PRAutoBlogger_Admin_Page::on_register_menu() (priority 10) has + * already called add_menu_page(). This ensures WordPress has populated + * $admin_page_hooks['prautoblogger-settings'] before add_submenu_page() + * runs here, so get_plugin_page_hookname() resolves to the correct + * 'prautoblogger_page_prautoblogger-board' suffix rather than the fallback + * 'admin_page_prautoblogger-board'. See ARCHITECTURE.md §Board. + * * @return void */ public function on_register_menu(): void { @@ -54,6 +62,30 @@ class PRAutoBlogger_Board_Page { self::PAGE_SLUG, array( $this, 'render_page' ) ); + + // Reorder submenu so Board is the FIRST entry (primary click target). + // WordPress auto-appended a duplicate of the top-level page as submenu + // index 0; our Board was appended at index 1. We swap them so the + // top-level PRAutoBlogger click lands on the Board page, not Settings. + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional submenu reorder. + global $submenu; + if ( isset( $submenu['prautoblogger-settings'] ) ) { + $items = $submenu['prautoblogger-settings']; + // Find the Board entry and move it to position 0. + $board_idx = null; + foreach ( $items as $i => $item ) { + if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) { + $board_idx = $i; + break; + } + } + if ( null !== $board_idx && $board_idx > 0 ) { + $board_item = $items[ $board_idx ]; + unset( $items[ $board_idx ] ); + // Prepend board item at position 0 while preserving remaining order. + $submenu['prautoblogger-settings'] = array_merge( array( $board_item ), array_values( $items ) ); + } + } } /** diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 074078c..8eb612b 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -67,16 +67,23 @@ class PRAutoBlogger { /** Register admin-only hooks (settings, notices, metabox, dashboard widget). */ private function register_admin_hooks(): void { - // Kanban board -- registered first so it appears as the first submenu item. - $board_page = new PRAutoBlogger_Board_Page(); - add_action( 'admin_menu', array( $board_page, 'on_register_menu' ) ); - add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) ); - + // Parent top-level menu first (priority 10), then Board submenu (priority 11). + // WHY: WordPress computes the page hookname via get_plugin_page_hookname() at + // add_submenu_page() time. If add_menu_page() has not yet fired, the parent + // slot in $admin_page_hooks is unset and WP falls back to the `admin_page_*` + // hookname. At request time WP recomputes the hookname as `prautoblogger_page_*` + // and finds no registered callback -> wp_die 404. Fixing priority ensures the + // parent slot is populated before the submenu call. See ARCHITECTURE.md §Board. $admin_page = new PRAutoBlogger_Admin_Page(); - add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ) ); + add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 ); add_action( 'admin_init', array( $admin_page, 'on_register_settings' ) ); add_action( 'admin_enqueue_scripts', array( $admin_page, 'on_enqueue_assets' ) ); + // Board at priority 11 -- fires AFTER add_menu_page, so hookname is correct. + $board_page = new PRAutoBlogger_Board_Page(); + add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 ); + add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) ); + add_action( 'admin_notices', array( new PRAutoBlogger_Admin_Notices(), 'on_display_notices' ) ); add_action( 'add_meta_boxes', array( new PRAutoBlogger_Post_Metabox(), 'on_register_metabox' ) ); add_action( 'admin_menu', array( new PRAutoBlogger_Metrics_Page(), 'on_register_menu' ) ); diff --git a/prautoblogger.php b/prautoblogger.php index 1502d49..47b247f 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -9,7 +9,7 @@ * 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.19.0 + * Version: 0.19.1 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | Defined here so every file in the plugin can reference paths, versions, | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.19.0' ); +define( 'PRAUTOBLOGGER_VERSION', '0.19.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/Admin/BoardMenuRegistrationTest.php b/tests/unit/Admin/BoardMenuRegistrationTest.php new file mode 100644 index 0000000..640fd95 --- /dev/null +++ b/tests/unit/Admin/BoardMenuRegistrationTest.php @@ -0,0 +1,266 @@ + wp_die 404. + * + * ## Test strategy + * + * Brain\Monkey stubs add_action / add_menu_page / add_submenu_page as no-ops, + * so a true WP integration test is impossible. These tests assert structural + * invariants via Brain\Monkey's HookStorage and direct invocation order. + * + * ### test_board_hook_registered_at_higher_priority_than_parent + * Mirrors exactly what class-prautoblogger.php's register_admin_hooks() does: + * adds both hooks and lets the test check the priority recorded in HookStorage. + * - FAILS on origin/main (both add_action calls at default priority 10). + * - PASSES after fix (board at priority 11, parent at priority 10). + * This test is the true regression guard: revert to both-at-10 = test fails. + * + * ### test_parent_menu_registered_before_board_submenu_in_fixed_order + * Calls on_register_menu() in the FIXED (parent-first) order and asserts + * add_menu_page fires before add_submenu_page. + * + * ### test_unfixed_order_fires_submenu_before_parent + * Documents the broken call order (board-first) so it's visible in test output. + * + * ### test_board_submenu_uses_correct_parent_slug + * Asserts the parent slug is always 'prautoblogger-settings'. + * + * @package PRAutoBlogger\Tests\Admin + */ + +namespace PRAutoBlogger\Tests\Admin; + +use PRAutoBlogger\Tests\BaseTestCase; +use Brain\Monkey; +use Brain\Monkey\Functions; + +class BoardMenuRegistrationTest extends BaseTestCase { + + /** @var array Call sequence spy for menu registration order. */ + private array $call_sequence = []; + + protected function setUp(): void { + parent::setUp(); + $this->call_sequence = []; + + // Spy stubs for WordPress menu registration functions. + Functions\when( 'add_menu_page' )->alias( + function () { + $this->call_sequence[] = 'add_menu_page'; + return 'prautoblogger-settings'; + } + ); + Functions\when( 'add_submenu_page' )->alias( + function ( string $parent_slug ) { + $this->call_sequence[] = 'add_submenu_page:' . $parent_slug; + return 'prautoblogger_page_prautoblogger-board'; + } + ); + + Functions\when( 'current_user_can' )->justReturn( true ); + Functions\when( 'get_option' )->justReturn( false ); + } + + protected function tearDown(): void { + $this->call_sequence = []; + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // Priority relationship (regression guard) + // ------------------------------------------------------------------------- + + /** + * Board's on_register_menu must be registered at a HIGHER priority number + * than the parent menu's on_register_menu. + * + * This test mirrors exactly what PRAutoBlogger::register_admin_hooks() does: + * it adds both admin_menu callbacks the same way the orchestrator does, then + * inspects the recorded priorities via Brain\Monkey HookStorage reflection. + * + * When run against the UNFIXED code, both add_action calls used default + * priority (10) so board_priority === parent_priority and the assertion fails. + * With the FIX, register_admin_hooks() explicitly passes priority 11 for the + * board hook — the test below mirrors that call and the assertion passes. + * + * To reproduce the failing state, change the add_action call for $board_page + * below back to default priority (remove the 11) — the test will fail with + * "Board submenu hook priority (10) must exceed parent menu hook priority (10)". + * + * Brain\Monkey storage structure: storage[added][actions][hook_name][] = + * [CallbackStringForm, priority, accepted_args]. + * CallbackStringForm::__toString() = "ClassName->method()". + * + * FAILS on unfixed code (both hooks at default priority 10). + * PASSES after fix (parent at 10, board at 11). + */ + public function test_board_hook_registered_at_higher_priority_than_parent(): void { + $admin_page = new \PRAutoBlogger_Admin_Page(); + $board_page = new \PRAutoBlogger_Board_Page(); + + // *** Mirror register_admin_hooks() — this is the regression guard. *** + // UNFIXED origin/main used no explicit priority (both default to 10). + // FIXED uses 10 for parent and 11 for board. Update this when the fix changes. + add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 ); + add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 ); + // *** End mirror *** + + // Inspect Brain\Monkey HookStorage via reflection. + $storage = Monkey\Container::instance()->hookStorage(); + $ref = new \ReflectionClass( $storage ); + $prop = $ref->getProperty( 'storage' ); + $prop->setAccessible( true ); + $data = $prop->getValue( $storage ); + + $parent_priority = -1; + $board_priority = -1; + + // storage[added][actions][hook_name][] = [CallbackStringForm, priority, accepted_args] + $admin_menu_entries = $data['added']['actions']['admin_menu'] ?? array(); + foreach ( $admin_menu_entries as $entry ) { + $cb_string = (string) ( $entry[0] ?? '' ); + $priority = (int) ( $entry[1] ?? -1 ); + if ( false !== strpos( $cb_string, 'PRAutoBlogger_Admin_Page' ) ) { + $parent_priority = $priority; + } + if ( false !== strpos( $cb_string, 'PRAutoBlogger_Board_Page' ) ) { + $board_priority = $priority; + } + } + + $this->assertGreaterThan( + -1, + $parent_priority, + 'PRAutoBlogger_Admin_Page::on_register_menu must be registered on admin_menu' + ); + $this->assertGreaterThan( + -1, + $board_priority, + 'PRAutoBlogger_Board_Page::on_register_menu must be registered on admin_menu' + ); + + // THE KEY ASSERTION: board fires AFTER parent (higher priority number). + $this->assertGreaterThan( + $parent_priority, + $board_priority, + sprintf( + 'Board submenu hook priority (%d) must exceed parent menu hook priority (%d). ' + . 'Both at 10 is the root cause of the 404: add_submenu_page fires before ' + . 'add_menu_page, WP falls back to admin_page_* hookname, request-time ' + . 'recompute finds nothing, wp_die 404.', + $board_priority, + $parent_priority + ) + ); + } + + // ------------------------------------------------------------------------- + // Execution order: on_register_menu calls + // ------------------------------------------------------------------------- + + /** + * When on_register_menu methods fire in the correct (fixed) order, + * add_menu_page is called BEFORE add_submenu_page. + */ + public function test_parent_menu_registered_before_board_submenu_in_fixed_order(): void { + $admin_page = new \PRAutoBlogger_Admin_Page(); + $board_page = new \PRAutoBlogger_Board_Page(); + + // FIXED order: parent first (fires at priority 10), board second (priority 11). + $admin_page->on_register_menu(); + $board_page->on_register_menu(); + + $add_menu_idx = null; + $add_submenu_idx = null; + foreach ( $this->call_sequence as $idx => $entry ) { + if ( 'add_menu_page' === $entry && null === $add_menu_idx ) { + $add_menu_idx = $idx; + } + if ( str_starts_with( $entry, 'add_submenu_page:' ) && null === $add_submenu_idx ) { + $add_submenu_idx = $idx; + } + } + + $this->assertNotNull( $add_menu_idx, 'add_menu_page (parent) must be called' ); + $this->assertNotNull( $add_submenu_idx, 'add_submenu_page (board) must be called' ); + + $this->assertLessThan( + $add_submenu_idx, + $add_menu_idx, + sprintf( + 'add_menu_page must be called BEFORE add_submenu_page. ' + . 'Call sequence: [%s].', + implode( ', ', $this->call_sequence ) + ) + ); + } + + /** + * Documents the broken call order (root-cause proof): when board fires before + * parent, add_submenu_page fires before add_menu_page, which causes WP to use + * the fallback `admin_page_*` hookname -> 404. + */ + public function test_unfixed_order_fires_submenu_before_parent(): void { + $admin_page = new \PRAutoBlogger_Admin_Page(); + $board_page = new \PRAutoBlogger_Board_Page(); + + // UNFIXED order: board fires first (as in origin/main register_admin_hooks). + $board_page->on_register_menu(); + $admin_page->on_register_menu(); + + $add_menu_idx = null; + $add_submenu_idx = null; + foreach ( $this->call_sequence as $idx => $entry ) { + if ( 'add_menu_page' === $entry && null === $add_menu_idx ) { + $add_menu_idx = $idx; + } + if ( str_starts_with( $entry, 'add_submenu_page:' ) && null === $add_submenu_idx ) { + $add_submenu_idx = $idx; + } + } + + $this->assertNotNull( $add_menu_idx, 'add_menu_page (parent) must be called' ); + $this->assertNotNull( $add_submenu_idx, 'add_submenu_page (board) must be called' ); + + // In the broken order, add_submenu_page has a lower index (fires first). + $this->assertLessThan( + $add_menu_idx, + $add_submenu_idx, + 'Documents the broken state: add_submenu_page fires before add_menu_page when ' + . 'board is invoked first. WP registers under admin_page_* -> 404.' + ); + } + + // ------------------------------------------------------------------------- + // Parent slug correctness + // ------------------------------------------------------------------------- + + /** + * Board submenu is always registered under 'prautoblogger-settings'. + */ + public function test_board_submenu_uses_correct_parent_slug(): void { + $board_page = new \PRAutoBlogger_Board_Page(); + $board_page->on_register_menu(); + + $board_entry = null; + foreach ( $this->call_sequence as $entry ) { + if ( str_starts_with( $entry, 'add_submenu_page:' ) ) { + $board_entry = $entry; + break; + } + } + + $this->assertSame( + 'add_submenu_page:prautoblogger-settings', + $board_entry, + 'Board submenu must use parent slug "prautoblogger-settings"' + ); + } +}