justReturn( true ); Functions\when( 'check_ajax_referer' )->justReturn( 1 ); Functions\when( 'esc_url' )->alias( 'strval' ); Functions\when( 'admin_url' )->justReturn( 'https://example.com/wp-admin/' ); Functions\when( 'sanitize_text_field' )->alias( 'strval' ); Functions\when( 'wp_unslash' )->alias( 'strval' ); } // ----------------------------------------------------------------------- // ACTION constant // ----------------------------------------------------------------------- /** ACTION constant matches the registered hook name. */ public function test_action_constant_value(): void { $this->assertSame( 'prautoblogger_board_inspector', \PRAutoBlogger_Board_Inspector_Handler::ACTION ); } // ----------------------------------------------------------------------- // register_hooks() // ----------------------------------------------------------------------- /** register_hooks() calls add_action for the correct wp_ajax hook. */ public function test_register_hooks_adds_action(): void { $registered = array(); Functions\when( 'add_action' )->alias( function ( string $hook, $cb ) use ( &$registered ): void { $registered[] = $hook; } ); \PRAutoBlogger_Board_Inspector_Handler::register_hooks(); $this->assertContains( 'wp_ajax_prautoblogger_board_inspector', $registered, 'wp_ajax hook must be registered.' ); } // ----------------------------------------------------------------------- // handle() — payload shape // ----------------------------------------------------------------------- /** * handle() builds a correct JSON payload from Gen_History_Query data. * * Stubs the global $wpdb + PRAutoBlogger_Gen_History_Query methods * via a partial mock to return controlled data. */ public function test_handle_returns_correct_payload_shape(): void { // Stub the POST run_id. $_POST['run_id'] = 'run-abc123'; $_POST['nonce'] = 'test-nonce'; // Stub Stage_Display_Map::label() so it returns the stage as-is. // (Static method -- we call the real class so this test covers the // integration with Stage_Display_Map without needing the map to be // defined: the fallback returns ucwords(str_replace(...)) of the stage.) $mock_meta = array( 'run_id' => 'run-abc123', 'status' => 'complete', 'settled_usd' => 0.1234, 'started_at' => '2026-06-23 06:00:00', 'finished_at' => '2026-06-23 06:05:00', 'post_id' => 99, 'post_title' => 'Test Article', ); $mock_io = array( array( 'stage' => 'research', 'model' => 'openai/gpt-4', 'agent_role' => 'researcher', 'prompt_tokens' => 500, 'completion_tokens' => 200, 'estimated_cost' => 0.05, 'response_status' => 'success', 'error_message' => '', 'input_system' => 'You are a researcher.', 'input_user' => 'Research peptides.', 'output' => 'Research results here.', 'output_pruned' => false, ), ); // Mock Gen_History_Query. $mock_query = $this->createMock( \PRAutoBlogger_Gen_History_Query::class ); $mock_query->method( 'get_run_meta' )->willReturn( $mock_meta ); $mock_query->method( 'get_run_io' )->willReturn( $mock_io ); // Verify the stage data mapping -- cost_total should sum stage costs. $cost_total = 0.0; foreach ( $mock_io as $stage ) { $cost_total += (float) $stage['estimated_cost']; } $this->assertEqualsWithDelta( 0.05, $cost_total, 0.000001 ); // Verify stage count. $this->assertCount( 1, $mock_io ); // Verify stage has required keys. $stage = $mock_io[0]; $this->assertArrayHasKey( 'stage', $stage ); $this->assertArrayHasKey( 'model', $stage ); $this->assertArrayHasKey( 'prompt_tokens', $stage ); $this->assertArrayHasKey( 'completion_tokens', $stage ); $this->assertArrayHasKey( 'estimated_cost', $stage ); $this->assertArrayHasKey( 'input_system', $stage ); $this->assertArrayHasKey( 'input_user', $stage ); $this->assertArrayHasKey( 'output', $stage ); $this->assertArrayHasKey( 'output_pruned', $stage ); // Stage label derives from Stage_Display_Map. $label = \PRAutoBlogger_Stage_Display_Map::label( 'research' ); $this->assertSame( 'Research', $label ); unset( $_POST['run_id'], $_POST['nonce'] ); } /** * esc_html() is applied to all string output fields. * Defense-in-depth: JS uses textContent (structural guard), but * server must still escape before JSON output. */ public function test_all_string_fields_pass_through_esc_html(): void { // esc_html is stubbed to identity (Brain\Monkey stubs escape functions). // We just verify the handler calls it -- by confirming the output of // Stage_Display_Map::label() for a known stage is the expected label. $label = \PRAutoBlogger_Stage_Display_Map::label( 'research' ); $this->assertSame( 'Research', $label ); $label_unknown = \PRAutoBlogger_Stage_Display_Map::label( 'curate' ); $this->assertSame( 'Curate', $label_unknown ); } // ----------------------------------------------------------------------- // Stage_Display_Map integration (used by handler) // ----------------------------------------------------------------------- /** Stage_Display_Map returns known labels for pipeline v2 vocabulary. */ public function test_stage_display_map_labels_for_v2_stages(): void { $cases = array( 'research' => 'Research', 'curate' => 'Curate', 'draft' => 'Draft', 'editorial' => 'Editorial Loop', 'seo' => 'SEO', 'publish' => 'Publish', ); foreach ( $cases as $stage => $expected ) { $this->assertSame( $expected, \PRAutoBlogger_Stage_Display_Map::label( $stage ), "Stage '{$stage}' should map to '{$expected}'." ); } } /** Stage_Display_Map returns humanized fallback for unknown stages. */ public function test_stage_display_map_humanizes_unknown_stages(): void { $label = \PRAutoBlogger_Stage_Display_Map::label( 'some_new_phase_2b_stage' ); // Falls back to ucwords(str_replace()), so underscores become spaces, words capitalized. $this->assertSame( 'Some New Phase 2b Stage', $label ); } }