feat: Pipeline Settings M1 — per-step model picker, system instructions, agent prompts (#170, v0.23.0)
Additive per-step Pipeline Settings page. Reuses the existing model_select picker + versioned prompt registry; no migration. QA APPROVE for eea4968; CI green. Agent-Session: pipeline-settings-m1-merge
This commit is contained in:
251
tests/unit/Admin/PipelineSettingsSaveHandlerTest.php
Normal file
251
tests/unit/Admin/PipelineSettingsSaveHandlerTest.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_Pipeline_Settings_Save_Handler.
|
||||
*
|
||||
* Locks the three critical contracts:
|
||||
* (1) Allowlist enforcement — unknown option names and unknown prompt slugs
|
||||
* are rejected before any DB write.
|
||||
* (2) Model option persistence — the value is read from $_POST[$option_name]
|
||||
* (the key that model-picker.js writes to), not from $_POST['model_id'].
|
||||
* (3) Prompt version creation — resolve_key_from_slug() round-trip and
|
||||
* create_version() is called with the canonical key and $activate = true.
|
||||
*
|
||||
* @see admin/class-pipeline-settings-save-handler.php
|
||||
* @see admin/class-pipeline-settings-step-map.php
|
||||
* @see CONVENTIONS.md §How To: Add a New Pipeline-Style Admin Page
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class PipelineSettingsSaveHandlerTest extends BaseTestCase {
|
||||
|
||||
/** @var array<string, mixed> Captured update_option() calls: option_name => value. */
|
||||
private array $saved_options = array();
|
||||
|
||||
/** @var array<int, array<string, mixed>> Captured create_version() calls. */
|
||||
private array $created_versions = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
$_POST = array();
|
||||
|
||||
// Nonce stubs.
|
||||
Functions\when( 'wp_verify_nonce' )->justReturn( true );
|
||||
Functions\when( 'wp_unslash' )->returnArg();
|
||||
Functions\when( 'sanitize_key' )->alias(
|
||||
static function ( $val ) {
|
||||
return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( (string) $val ) );
|
||||
}
|
||||
);
|
||||
Functions\when( 'sanitize_text_field' )->alias(
|
||||
static function ( $val ) { return trim( (string) $val ); }
|
||||
);
|
||||
Functions\when( 'sanitize_textarea_field' )->alias(
|
||||
static function ( $val ) { return trim( strip_tags( (string) $val ) ); }
|
||||
);
|
||||
Functions\when( 'current_user_can' )->justReturn( true );
|
||||
Functions\when( 'esc_html' )->alias( static function ( $v ) { return htmlspecialchars( (string) $v, ENT_QUOTES ); } );
|
||||
|
||||
// Capture update_option().
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $name, $value ) {
|
||||
$this->saved_options[ $name ] = $value;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
// Stub WP user.
|
||||
$user = new \stdClass();
|
||||
$user->user_login = 'rhys';
|
||||
Functions\when( 'wp_get_current_user' )->justReturn( $user );
|
||||
|
||||
// Stub prompt registry methods via static overrides.
|
||||
$this->created_versions = array();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$_POST = array();
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$this->saved_options = array();
|
||||
$this->created_versions = array();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § ALLOWLIST ENFORCEMENT — model options
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* An unknown model_option name must be rejected before any update_option().
|
||||
*/
|
||||
public function test_handle_model_save_rejects_unknown_option(): void {
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid',
|
||||
'pipeline_action' => 'save_model',
|
||||
'model_option' => 'prautoblogger_evil_option',
|
||||
'prautoblogger_evil_option' => 'some/model-id',
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'error', $result['status'] );
|
||||
$this->assertArrayNotHasKey( 'prautoblogger_evil_option', $this->saved_options );
|
||||
}
|
||||
|
||||
/**
|
||||
* A valid model option is accepted and persisted via update_option().
|
||||
*/
|
||||
public function test_handle_model_save_persists_valid_option(): void {
|
||||
$option = 'prautoblogger_research_model';
|
||||
$model = 'google/gemini-2.0-flash-lite';
|
||||
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid',
|
||||
'pipeline_action' => 'save_model',
|
||||
'model_option' => $option,
|
||||
$option => $model,
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'saved', $result['status'] );
|
||||
$this->assertArrayHasKey( $option, $this->saved_options );
|
||||
$this->assertSame( $model, $this->saved_options[ $option ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* The save handler reads the value from $_POST[$option_name], not from
|
||||
* $_POST['model_id'] — proving the POST key fix for the model picker.
|
||||
*
|
||||
* When model_id is present but the option-name key is absent, the saved
|
||||
* value must be empty (not the model_id value).
|
||||
*/
|
||||
public function test_model_save_reads_from_option_name_key_not_model_id(): void {
|
||||
$option = 'prautoblogger_analysis_model';
|
||||
$model = 'anthropic/claude-3.5-haiku';
|
||||
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid',
|
||||
'pipeline_action' => 'save_model',
|
||||
'model_option' => $option,
|
||||
// model_id is the OLD (broken) key — it must NOT be used.
|
||||
'model_id' => $model,
|
||||
// Correct key ($option_name) is absent to prove the handler
|
||||
// reads the right key and saves empty, not the model_id value.
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'saved', $result['status'] );
|
||||
// If the old model_id key were used the value would be $model;
|
||||
// the correct key ($option) is absent so the value must be ''.
|
||||
$this->assertSame( '', $this->saved_options[ $option ] ?? 'NOT_SET' );
|
||||
}
|
||||
|
||||
/**
|
||||
* The correct flow: option-name key carries the value, model_id is absent.
|
||||
* This is what model-picker.js actually posts (writes to hidden input by id).
|
||||
*/
|
||||
public function test_model_save_correct_post_key_flow(): void {
|
||||
$option = 'prautoblogger_writing_model';
|
||||
$model = 'openai/gpt-4o-mini';
|
||||
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid',
|
||||
'pipeline_action' => 'save_model',
|
||||
'model_option' => $option,
|
||||
// The picker writes to name="prautoblogger_writing_model" directly.
|
||||
$option => $model,
|
||||
// No model_id key — matches real model-picker.js behavior.
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'saved', $result['status'] );
|
||||
$this->assertSame( $model, $this->saved_options[ $option ] );
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § ALLOWLIST ENFORCEMENT — prompt keys
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* An unknown prompt slug must be rejected before any registry write.
|
||||
*/
|
||||
public function test_handle_prompt_save_rejects_unknown_slug(): void {
|
||||
// create_version is never reached: the allowlist rejects the slug first.
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid',
|
||||
'pipeline_action' => 'save_prompt',
|
||||
'prompt_key' => 'not-a-real-key',
|
||||
'prompt_body' => 'some body',
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'error', $result['status'] );
|
||||
$this->assertStringContainsString( 'Unknown prompt key', $result['message'] );
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § CAPABILITY / NONCE GATE
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Users without manage_options are rejected before nonce verification.
|
||||
*/
|
||||
public function test_rejects_insufficient_capability(): void {
|
||||
Functions\when( 'current_user_can' )->justReturn( false );
|
||||
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid',
|
||||
'pipeline_action' => 'save_model',
|
||||
'model_option' => 'prautoblogger_research_model',
|
||||
'prautoblogger_research_model' => 'some/model',
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'error', $result['status'] );
|
||||
$this->assertEmpty( $this->saved_options );
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed nonce returns error without any persistence.
|
||||
*/
|
||||
public function test_rejects_invalid_nonce(): void {
|
||||
Functions\when( 'wp_verify_nonce' )->justReturn( false );
|
||||
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'bad',
|
||||
'pipeline_action' => 'save_model',
|
||||
'model_option' => 'prautoblogger_research_model',
|
||||
'prautoblogger_research_model' => 'some/model',
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'error', $result['status'] );
|
||||
$this->assertEmpty( $this->saved_options );
|
||||
}
|
||||
|
||||
/**
|
||||
* A GET request returns idle without any processing.
|
||||
*/
|
||||
public function test_get_request_returns_idle(): void {
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$_POST = array();
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'idle', $result['status'] );
|
||||
$this->assertEmpty( $this->saved_options );
|
||||
}
|
||||
}
|
||||
169
tests/unit/Admin/PipelineSettingsStepMapTest.php
Normal file
169
tests/unit/Admin/PipelineSettingsStepMapTest.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_Pipeline_Settings_Step_Map.
|
||||
*
|
||||
* Regression guards that ensure allowed_prompt_keys() and allowed_model_options()
|
||||
* do not silently drift from the step definitions, and that the slug round-trip
|
||||
* used by the save handler works correctly (including keys that contain
|
||||
* underscores, which cause dot→hyphen→sanitize_key to behave unexpectedly if
|
||||
* not handled correctly).
|
||||
*
|
||||
* @see admin/class-pipeline-settings-step-map.php
|
||||
* @see admin/class-pipeline-settings-save-handler.php — resolve_key_from_slug()
|
||||
* @see CONVENTIONS.md §How To: Add a New Pipeline-Style Admin Page
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class PipelineSettingsStepMapTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
// sanitize_key is used by resolve_key_from_slug inside save handler.
|
||||
Functions\when( 'sanitize_key' )->alias(
|
||||
static function ( $val ) {
|
||||
return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( (string) $val ) );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § STEP DEFINITIONS — structural integrity
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Every step must have the mandatory fields.
|
||||
*/
|
||||
public function test_every_step_has_required_fields(): void {
|
||||
$required = array( 'id', 'label', 'icon', 'capability', 'description' );
|
||||
foreach ( \PRAutoBlogger_Pipeline_Settings_Step_Map::steps() as $step ) {
|
||||
foreach ( $required as $field ) {
|
||||
$this->assertArrayHasKey(
|
||||
$field,
|
||||
$step,
|
||||
"Step '{$step['id']}' is missing required field '{$field}'."
|
||||
);
|
||||
$this->assertNotEmpty(
|
||||
$step[ $field ],
|
||||
"Step '{$step['id']}' field '{$field}' must not be empty."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step ids are unique — no two steps share the same machine id.
|
||||
*/
|
||||
public function test_step_ids_are_unique(): void {
|
||||
$ids = array_column( \PRAutoBlogger_Pipeline_Settings_Step_Map::steps(), 'id' );
|
||||
$this->assertSame( count( $ids ), count( array_unique( $ids ) ), 'Step ids must be unique.' );
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § ALLOWLISTS — regression guards
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* allowed_prompt_keys() must include every system_key and agent_key
|
||||
* declared in steps(), with no duplicates.
|
||||
*/
|
||||
public function test_allowed_prompt_keys_covers_all_step_keys(): void {
|
||||
$expected = array();
|
||||
foreach ( \PRAutoBlogger_Pipeline_Settings_Step_Map::steps() as $step ) {
|
||||
if ( ! empty( $step['system_key'] ) ) {
|
||||
$expected[] = $step['system_key'];
|
||||
}
|
||||
foreach ( ( $step['agent_keys'] ?? array() ) as $k ) {
|
||||
$expected[] = $k;
|
||||
}
|
||||
}
|
||||
$expected = array_unique( $expected );
|
||||
$actual = \PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_prompt_keys();
|
||||
|
||||
sort( $expected );
|
||||
sort( $actual );
|
||||
$this->assertSame( $expected, $actual, 'allowed_prompt_keys() must match all system + agent keys from steps().' );
|
||||
}
|
||||
|
||||
/**
|
||||
* allowed_model_options() must include every non-null model_option from steps().
|
||||
*/
|
||||
public function test_allowed_model_options_covers_all_step_options(): void {
|
||||
$expected = array();
|
||||
foreach ( \PRAutoBlogger_Pipeline_Settings_Step_Map::steps() as $step ) {
|
||||
if ( ! empty( $step['model_option'] ) ) {
|
||||
$expected[] = $step['model_option'];
|
||||
}
|
||||
}
|
||||
$expected = array_unique( $expected );
|
||||
$actual = \PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_model_options();
|
||||
|
||||
sort( $expected );
|
||||
sort( $actual );
|
||||
$this->assertSame( $expected, $actual, 'allowed_model_options() must match all model_option values from steps().' );
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § SLUG ROUND-TRIP — resolve_key_from_slug()
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Standard key: 'research.system' slug = 'research-system'.
|
||||
*/
|
||||
public function test_slug_round_trip_simple_key(): void {
|
||||
$key = 'research.system';
|
||||
$slug = sanitize_key( str_replace( '.', '-', $key ) );
|
||||
$this->assertSame( 'research-system', $slug );
|
||||
}
|
||||
|
||||
/**
|
||||
* Key with underscore: 'content.single_pass' must slug to 'content-single_pass'.
|
||||
* sanitize_key() preserves underscores, so the round-trip is unambiguous.
|
||||
*/
|
||||
public function test_slug_round_trip_key_with_underscore(): void {
|
||||
$key = 'content.single_pass';
|
||||
$slug = sanitize_key( str_replace( '.', '-', $key ) );
|
||||
// Dot → hyphen; underscore preserved by sanitize_key.
|
||||
$this->assertSame( 'content-single_pass', $slug );
|
||||
}
|
||||
|
||||
/**
|
||||
* All keys in allowed_prompt_keys() must produce distinct slugs (no collision).
|
||||
* A collision would make the save handler unable to resolve one of the keys.
|
||||
*/
|
||||
public function test_all_prompt_key_slugs_are_unique(): void {
|
||||
$keys = \PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_prompt_keys();
|
||||
$slugs = array_map(
|
||||
static function ( $key ) {
|
||||
return sanitize_key( str_replace( '.', '-', $key ) );
|
||||
},
|
||||
$keys
|
||||
);
|
||||
$this->assertSame(
|
||||
count( $slugs ),
|
||||
count( array_unique( $slugs ) ),
|
||||
'Every allowed prompt key must produce a unique slug; a collision blocks save.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* find() returns the correct step by id.
|
||||
*/
|
||||
public function test_find_returns_matching_step(): void {
|
||||
$step = \PRAutoBlogger_Pipeline_Settings_Step_Map::find( 'research' );
|
||||
$this->assertNotNull( $step );
|
||||
$this->assertSame( 'research', $step['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* find() returns null for an unknown id.
|
||||
*/
|
||||
public function test_find_returns_null_for_unknown_id(): void {
|
||||
$this->assertNull( \PRAutoBlogger_Pipeline_Settings_Step_Map::find( 'does_not_exist' ) );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user