feat: Article Dossier page + metabox dossier link + board deeplinks (v0.19.2)
M2 milestone: per-article read-only dossier page, accessible from board card deep-links and the slimmed post metabox. ## New admin page - class-dossier-page.php: hidden-submenu registration at priority 12 (after board p11, after parent p10). Secured by manage_options + nonce. Static url_for_post() provides canonical deep-link. - class-dossier-data-assembler.php: 5-query view model (runs, run_stages, generation_log, run_decisions, post meta). No N+1. Legacy graceful state: posts with no run record return has_run=false. - class-dossier-gen-log-query.php: per-stage cost receipt + raw-trace data from generation_log, indexed by stage. ## Templates - dossier-page.php: two-column layout (stage sections + sidebar with cost receipt). Verdict pills. Editorial decisions section. - dossier-stage-section.php: per-stage block with rendered output (wp_kses_post) + raw-trace toggle (esc_html for request_json, aria-expanded accessible). - metabox-dossier-link.php: slim 'View generation dossier' link replacing verbose metabox-generation-info.php. ## Assets - dossier.css: warm editorial palette, verdict pills, cost receipt, toggle. - dossier.js: vanilla JS raw-trace toggle (DOMContentLoaded, aria-expanded). - board.js: dossier click_action handler before review handler. ## Tests - DossierMenuRegistrationTest.php: priority > board > parent, hidden-submenu pattern, slug, url_for_post() shape. - DossierDataAssemblerTest.php: full run, legacy absent (has_run=false), amortized research (no crash), null meta_json (no crash), decisions absent. ## Bindings satisfied 1. split-class-prautoblogger: DONE (prior commit) 2. Menu ordering: dossier p12 > board p11 > parent p10 3. peptide-e2e smoke: PR 2 (separate repo, this milestone) 4. Raw-trace redaction: request_json via esc_html(), output via wp_kses_post() 5. Legacy graceful: has_run=false path, no notices/fatals 6. v0.19.2 + CHANGELOG + ARCHITECTURE.md in this commit; phpcbf run clean Agent-Session: claude-sonnet-4-6
This commit is contained in:
241
tests/unit/Admin/DossierDataAssemblerTest.php
Normal file
241
tests/unit/Admin/DossierDataAssemblerTest.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Dossier_Data_Assembler -- view model assembly.
|
||||
*
|
||||
* Covers:
|
||||
* - Full modern run: runs + stages + gen_log + decisions
|
||||
* - Legacy absent: post has no run_id meta
|
||||
* - Zero post_id: bogus ID
|
||||
* - Amortized research: gen_log row with prompt_version=NULL, agent_role=''
|
||||
* - Pruned meta_json: stage row with meta_json=NULL, status=done
|
||||
* - Decisions present and absent
|
||||
*
|
||||
* All DB queries are mocked via $wpdb mock. No live DB required.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class DossierDataAssemblerTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||
private $wpdb;
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock gen_log_query. */
|
||||
private $log_query;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
|
||||
// Stub PRAutoBlogger_Run_Stage_State::is_available() to return true.
|
||||
// Static method -- stubbed via wpdb table check returning the table name.
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
|
||||
// table_name returns wp_prautoblogger_run_stages.
|
||||
$this->wpdb->prefix = 'wp_';
|
||||
|
||||
// is_available() calls $wpdb->get_var(SHOW TABLES LIKE ...) -- return the table.
|
||||
$run_stages_table = 'wp_prautoblogger_run_stages';
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( $sql ) use ( $run_stages_table ) {
|
||||
// Return something so prepare always works.
|
||||
return $sql;
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( null );
|
||||
Functions\when( 'get_post_meta' )->justReturn( '' );
|
||||
Functions\when( 'admin_url' )->alias( function ( $path = '' ) {
|
||||
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
|
||||
} );
|
||||
|
||||
$this->log_query = $this->getMockBuilder( \PRAutoBlogger_Dossier_Gen_Log_Query::class )
|
||||
->onlyMethods( array( 'get_by_run' ) )
|
||||
->getMock();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Zero / invalid post ID
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_zero_post_id_returns_no_run(): void {
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 0 );
|
||||
|
||||
$this->assertFalse( $result['has_run'] );
|
||||
$this->assertEmpty( $result['stages'] );
|
||||
}
|
||||
|
||||
public function test_nonexistent_post_returns_no_run(): void {
|
||||
Functions\when( 'get_post' )->justReturn( null );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 9999 );
|
||||
|
||||
$this->assertFalse( $result['has_run'] );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Legacy post (no run_id meta)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_legacy_post_no_run_id_returns_no_run(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 100;
|
||||
$post->post_title = 'Old Article';
|
||||
$post->post_status = 'publish';
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->justReturn( '' );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 100 );
|
||||
|
||||
$this->assertFalse( $result['has_run'],
|
||||
'Legacy post without run_id must return has_run=false' );
|
||||
$this->assertSame( 'Old Article', $result['post_title'] );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Full modern run
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_full_run_assembles_correctly(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 925;
|
||||
$post->post_title = 'BPC-157 Research Overview';
|
||||
$post->post_status = 'publish';
|
||||
|
||||
$run_id = 'abc-123-def-456';
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
function ( $post_id, $key, $single ) use ( $run_id ) {
|
||||
if ( '_prautoblogger_run_id' === $key ) { return $run_id; }
|
||||
if ( '_prautoblogger_editor_verdict' === $key ) { return 'approved'; }
|
||||
if ( '_prautoblogger_article_type' === $key ) { return 'authority'; }
|
||||
return '';
|
||||
}
|
||||
);
|
||||
|
||||
// Mock runs table query.
|
||||
$this->wpdb->method( 'get_row' )->willReturn( array(
|
||||
'run_id' => $run_id,
|
||||
'status' => 'done',
|
||||
'started_at' => '2026-06-12 03:00:00',
|
||||
'finished_at' => '2026-06-12 03:04:00',
|
||||
'settled_usd' => '0.012000',
|
||||
) );
|
||||
|
||||
// Mock run_stages query -- get_results returns stage rows.
|
||||
$stage_rows = array(
|
||||
array( 'id' => 1, 'run_id' => $run_id, 'stage' => 'draft', 'agent_role' => 'writer', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.008', 'meta_json' => wp_json_encode( array( 'output' => 'Draft content here.' ) ), 'started_at' => '2026-06-12 03:01:00', 'finished_at' => '2026-06-12 03:02:00' ),
|
||||
array( 'id' => 2, 'run_id' => $run_id, 'stage' => 'review', 'agent_role' => 'editor', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.004', 'meta_json' => wp_json_encode( array( 'output' => 'Looks good.' ) ), 'started_at' => '2026-06-12 03:02:00', 'finished_at' => '2026-06-12 03:03:00' ),
|
||||
);
|
||||
|
||||
// is_available check: get_var returns the table name.
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( $stage_rows );
|
||||
|
||||
$this->log_query->method( 'get_by_run' )->willReturn( array(
|
||||
'draft' => array( array( 'model' => 'gpt-4', 'prompt_version' => '3', 'agent_role' => 'writer', 'prompt_tokens' => 500, 'completion_tokens' => 1000, 'estimated_cost' => '0.008', 'request_json' => '{"model":"gpt-4"}', 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:01:00' ) ),
|
||||
'review' => array( array( 'model' => 'gpt-4', 'prompt_version' => '2', 'agent_role' => 'editor', 'prompt_tokens' => 200, 'completion_tokens' => 50, 'estimated_cost' => '0.004', 'request_json' => '{"model":"gpt-4"}', 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:02:00' ) ),
|
||||
) );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 925 );
|
||||
|
||||
$this->assertTrue( $result['has_run'], 'Modern run must have has_run=true' );
|
||||
$this->assertSame( $run_id, $result['run_id'] );
|
||||
$this->assertSame( 'approved', $result['verdict'] );
|
||||
$this->assertSame( 'authority', $result['tier'] );
|
||||
$this->assertNotEmpty( $result['stages'], 'Stage rows must be present' );
|
||||
$this->assertNotEmpty( $result['gen_log_index'], 'Gen_log index must be present' );
|
||||
|
||||
// Check draft stage has display_output set.
|
||||
$draft_key = 'draft:writer';
|
||||
$this->assertArrayHasKey( $draft_key, $result['stages'] );
|
||||
$this->assertSame( 'Draft content here.', $result['stages'][ $draft_key ]['display_output'] );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Amortized research row (pv=null, role='')
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_amortized_research_row_renders_gracefully(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 930;
|
||||
$post->post_title = 'TB-500 Article';
|
||||
$post->post_status = 'publish';
|
||||
|
||||
$run_id = 'llm-research-run-001';
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
function ( $post_id, $key, $single ) use ( $run_id ) {
|
||||
return '_prautoblogger_run_id' === $key ? $run_id : '';
|
||||
}
|
||||
);
|
||||
|
||||
$this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => $run_id, 'status' => 'done', 'started_at' => '2026-06-12 03:00:00' ) );
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
|
||||
// Amortized research row: pv=null, role=''.
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array(
|
||||
array( 'id' => 1, 'run_id' => $run_id, 'stage' => 'llm_research', 'agent_role' => '', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.001', 'meta_json' => null, 'started_at' => '2026-06-12 03:00:00', 'finished_at' => '2026-06-12 03:00:30' ),
|
||||
) );
|
||||
|
||||
$this->log_query->method( 'get_by_run' )->willReturn( array(
|
||||
'llm_research' => array( array( 'model' => 'gpt-4', 'prompt_version' => null, 'agent_role' => '', 'prompt_tokens' => 100, 'completion_tokens' => 50, 'estimated_cost' => '0.001', 'request_json' => null, 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:00:00' ) ),
|
||||
) );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
|
||||
// Must not throw or produce fatal.
|
||||
$result = $assembler->assemble( 930 );
|
||||
|
||||
$this->assertTrue( $result['has_run'] );
|
||||
|
||||
// Stage is present; display_output is null (meta_json was null = pruned).
|
||||
$llm_key = 'llm_research:';
|
||||
$this->assertArrayHasKey( $llm_key, $result['stages'] );
|
||||
$this->assertNull( $result['stages'][ $llm_key ]['display_output'],
|
||||
'Amortized research with null meta_json must have null display_output' );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Decisions present and absent
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_decisions_absent_returns_empty_array(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 901; $post->post_title = 'Test'; $post->post_status = 'publish';
|
||||
$run_id = 'no-decisions-run';
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->alias( fn( $id, $key ) => '_prautoblogger_run_id' === $key ? $run_id : '' );
|
||||
$this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => $run_id, 'status' => 'done' ) );
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() ); // no stages, no decisions
|
||||
$this->log_query->method( 'get_by_run' )->willReturn( array() );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 901 );
|
||||
|
||||
$this->assertIsArray( $result['decisions'] );
|
||||
$this->assertEmpty( $result['decisions'] );
|
||||
}
|
||||
}
|
||||
149
tests/unit/Admin/DossierMenuRegistrationTest.php
Normal file
149
tests/unit/Admin/DossierMenuRegistrationTest.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for Article Dossier admin page menu registration.
|
||||
*
|
||||
* Regression guard for the menu-ordering rule (v0.19.1 lesson):
|
||||
* any submenu registered BEFORE add_menu_page fires will resolve under the
|
||||
* fallback hookname `admin_page_*` instead of `prautoblogger_page_*`, causing
|
||||
* a wp_die 404 at request time.
|
||||
*
|
||||
* The dossier page uses a hidden-submenu pattern (removed from $submenu after
|
||||
* registration) at priority 12 -- after board (11) and parent (10).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class DossierMenuRegistrationTest extends BaseTestCase {
|
||||
|
||||
/** @var array<int, string> Call sequence spy. */
|
||||
private array $call_sequence = [];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->call_sequence = [];
|
||||
|
||||
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, string $page_title, string $menu_title, string $capability, string $menu_slug ) {
|
||||
$this->call_sequence[] = 'add_submenu_page:' . $menu_slug;
|
||||
return 'prautoblogger_page_' . $menu_slug;
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'current_user_can' )->justReturn( true );
|
||||
Functions\when( 'get_option' )->justReturn( false );
|
||||
Functions\when( 'admin_url' )->alias( function ( $path = '' ) {
|
||||
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
|
||||
} );
|
||||
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test isolation.
|
||||
$GLOBALS['submenu'] = array();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$this->call_sequence = [];
|
||||
unset( $GLOBALS['submenu'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dossier on_register_menu must be hooked at priority > board (11).
|
||||
* Mirrors the bindings in register_admin_hooks():
|
||||
* parent at 10, board at 11, dossier at 12.
|
||||
*
|
||||
* FAILS if dossier drops to priority ≤ 11 (same as or before board).
|
||||
* FAILS if dossier drops to priority ≤ 10 (same as or before parent).
|
||||
*/
|
||||
public function test_dossier_hook_registered_after_board(): void {
|
||||
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||
$board_page = new \PRAutoBlogger_Board_Page();
|
||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||
|
||||
// Mirror class-prautoblogger.php register_admin_hooks() priorities.
|
||||
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
||||
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
|
||||
|
||||
$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;
|
||||
$dossier_priority = -1;
|
||||
|
||||
$admin_menu_entries = $data['added']['actions']['admin_menu'] ?? array();
|
||||
foreach ( $admin_menu_entries as $entry ) {
|
||||
$cb = (string) ( $entry[0] ?? '' );
|
||||
$priority = (int) ( $entry[1] ?? -1 );
|
||||
if ( false !== strpos( $cb, 'PRAutoBlogger_Admin_Page' ) ) { $parent_priority = $priority; }
|
||||
if ( false !== strpos( $cb, 'PRAutoBlogger_Board_Page' ) ) { $board_priority = $priority; }
|
||||
if ( false !== strpos( $cb, 'PRAutoBlogger_Dossier_Page' ) ) { $dossier_priority = $priority; }
|
||||
}
|
||||
|
||||
$this->assertGreaterThan( $parent_priority, $board_priority,
|
||||
'Board priority must exceed parent priority.' );
|
||||
$this->assertGreaterThan( $board_priority, $dossier_priority,
|
||||
'Dossier priority must exceed board priority to ensure hookname resolves correctly.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Dossier submenu slug must be 'prautoblogger-dossier'.
|
||||
*/
|
||||
public function test_dossier_uses_correct_slug(): void {
|
||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||
$dossier_page->on_register_menu();
|
||||
|
||||
$found_slug = null;
|
||||
foreach ( $this->call_sequence as $entry ) {
|
||||
if ( str_starts_with( $entry, 'add_submenu_page:' ) ) {
|
||||
$found_slug = substr( $entry, strlen( 'add_submenu_page:' ) );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame( \PRAutoBlogger_Dossier_Page::PAGE_SLUG, $found_slug,
|
||||
'Dossier must register under PAGE_SLUG.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Dossier submenu parent must be 'prautoblogger-settings'.
|
||||
*/
|
||||
public function test_dossier_parent_slug_is_settings(): void {
|
||||
$captured_parent = null;
|
||||
Functions\when( 'add_submenu_page' )->alias(
|
||||
function ( string $parent_slug ) use ( &$captured_parent ) {
|
||||
$captured_parent = $parent_slug;
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||
$dossier_page->on_register_menu();
|
||||
|
||||
$this->assertSame( 'prautoblogger-settings', $captured_parent,
|
||||
'Dossier must use prautoblogger-settings as parent.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* url_for_post() returns a URL containing the dossier slug and post_id.
|
||||
*/
|
||||
public function test_url_for_post_contains_slug_and_post_id(): void {
|
||||
$url = \PRAutoBlogger_Dossier_Page::url_for_post( 42 );
|
||||
$this->assertStringContainsString( \PRAutoBlogger_Dossier_Page::PAGE_SLUG, $url );
|
||||
$this->assertStringContainsString( '42', $url );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user