diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e026951..a9435ac 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -866,6 +866,35 @@ Kept here for history. Until v0.8.2 the default image backend was FLUX.1 schnell ### #15: Optional Cloudflare AI Gateway in front of OpenRouter We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged. +### #25: Pipeline Settings page — per-step model, prompts, params (v0.23.0, M1) + +**Scope (M1 — additive):** A new "Pipeline" wp-admin submenu page +(`prautoblogger-pipeline`, slug `prautoblogger_page_prautoblogger-pipeline`) that +surfaces per-step configuration for every LLM stage. The page is ADDITIVE — the +existing Settings sections (AI Models, Content, Sources) remain in place. Decomposition +into stage-exclusive panels is M2. + +**Classes:** `PRAutoBlogger_Pipeline_Settings_Page` (registration + asset enqueue), +`PRAutoBlogger_Pipeline_Settings_Renderer` (view data assembly + template include), +`PRAutoBlogger_Pipeline_Settings_Save_Handler` (nonce + capability + sanitize + write), +`PRAutoBlogger_Pipeline_Settings_Step_Map` (canonical step definitions + key allowlists). + +**Model picker reuse:** `PRAutoBlogger_OpenRouter_Model_Field::render()` is called +directly — no fork, no new dropdown. The stage map in `get_stages_for_setting()` was +extended to include `prautoblogger_research_model → [research, llm_research]` and +`prautoblogger_image_model → [image_a, image_b]` so cost-preview is stage-accurate. + +**Prompt writes:** Saving a prompt creates a new immutable row in `wp_prautoblogger_prompts` +via `PRAutoBlogger_Prompt_Registry_Writer::create_version()` and activates it. +Only the keys listed in `PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_prompt_keys()` +may be updated through this page (allowlist enforced in the save handler). + +**Security:** `manage_options` capability check + nonce verification on every POST. +Prompt bodies are sanitized with `sanitize_textarea_field()`. + +**No new options:** M1 adds no persistent `wp_option` keys. Uninstall unchanged. + + --- ### #22: Pipeline v2 Phase 1 substrate (v0.18.0, db 1.2.0, June 2026) diff --git a/CHANGELOG.md b/CHANGELOG.md index f00d1c8..3293464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,76 @@ 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.23.0] - 2026-06-22 + +### Added +- **Pipeline Settings (M1 — additive):** New "Pipeline" wp-admin submenu page + (`prautoblogger-pipeline`) with a step rail covering all LLM stages: Research, + Analysis, Writer, Editorial, Image. Per-step panels expose the existing model + picker (`PRAutoBlogger_OpenRouter_Model_Field`), system-instruction editor, and + agent-prompt editor(s) — all bound to the live versioned prompt registry + (`PRAutoBlogger_Prompt_Registry`). Saving a prompt creates a new immutable version; + reset-to-default available. Active version shown per panel. Writer panel marks + inactive sub-stage prompts. No existing Settings sections changed in M1 (both + surfaces edit the same options / prompts table). +- **`get_stages_for_setting()` extended (text→text only):** `prautoblogger_research_model → + [research, llm_research]` added for stage-accurate cost-preview. Image model + cost preview intentionally omitted (shows static $/image price in picker trigger + instead — token-based formula does not apply). +- New files: `includes/admin/class-pipeline-settings-page.php`, + `class-pipeline-settings-renderer.php`, `class-pipeline-settings-save-handler.php`, + `class-pipeline-settings-step-map.php`; `assets/css/pipeline-settings.css`; + `assets/js/pipeline-settings.js`; `templates/admin/pipeline-settings-page.php`; + `CONTEXT.md` (domain glossary, DoD v1.2.0 §7). + +### Fixed (QA REQUEST-CHANGES — commit e6550e1) +- **Model save POST key (P1-1):** `handle_model_save()` now reads the model value + from `$_POST[$option_name]` — the key that `PRAutoBlogger_OpenRouter_Model_Field::render()` + emits as `` and that `model-picker.js` writes to by DOM id. + The old code read `$_POST['model_id']`, which was never present in the POST body, + silently overwriting the stored model with an empty string on every "Save Model" click. + Removed the dead `prautoblogger:model_selected` event listener in `pipeline-settings.js` + (the event was never fired by `model-picker.js`). +- **Renderer: no business logic in template (P2-3):** `PRAutoBlogger_Cost_Reporter` + instantiation moved from `pipeline-settings-page.php` template into + `Renderer::render()`; template now receives `$view['monthly_spend']` and + `$view['budget']` as plain scalars. +- **Renderer: dead DB query removed (P2-2):** `$all_versions` assignment in + `build_step_data()` removed (it triggered a `SELECT * FROM wp_prautoblogger_prompts` + query on every page load but the result was never used). +- **Image cost-preview dead config resolved (P2-1):** Removed unreachable + `prautoblogger_image_model → [image_a, image_b]` map entry from + `get_stages_for_setting()` and added a comment explaining the intentional design. + +### Docs +- **CONVENTIONS.md (P1-2):** Added `## How To: Add a New Pipeline-Style Admin Page` + section documenting the four-class split, registration priority, renderer contract, + POST key convention for the model picker, nonce/cap pattern, allowlist enforcement, + prompt key slug round-trip, and test requirements. +- **CONTEXT.md (P2-4):** Created root domain glossary covering all new Pipeline + Settings terms (step, step rail, step panel, pipeline-ui, system instruction, + agent prompt, system_key, agent_key, prompt key, prompt slug, reset-to-default, + step map, save handler) and prior prompt-registry terms. + +### Tests +- **PHPUnit (P1-3):** Added `tests/unit/Admin/PipelineSettingsSaveHandlerTest.php` + covering allowlist enforcement (unknown option/slug rejected), correct POST key + reading (`$_POST[$option_name]` not `$_POST['model_id']`), capability gate, + nonce gate, and GET idle return. +- **PHPUnit (P1-3):** Added `tests/unit/Admin/PipelineSettingsStepMapTest.php` + covering `allowed_prompt_keys()` / `allowed_model_options()` regression guards, + step field completeness, unique step ids, slug round-trip for simple and + underscore-containing keys, and `find()` behaviour. +- **PHPUnit fix:** Removed invalid `Brain\Monkey\Functions\when()` stub for + `PRAutoBlogger_Prompt_Registry_Writer::create_version` in + `PipelineSettingsSaveHandlerTest::test_handle_prompt_save_rejects_unknown_slug`. + Brain\Monkey's `Functions\when()` stubs only global PHP functions, not static + class methods; the stub was also unnecessary because the allowlist rejection + returns before `create_version()` is ever reached. +- **CONVENTIONS.md fix:** Corrected slug example for keys containing underscores: + `content.single_pass` → `content-single_pass` (not `content-single-pass`); + `sanitize_key()` preserves underscores so the round-trip is unambiguous. + ## [Unreleased - Internal] ### Internal diff --git a/CONTEXT.md b/CONTEXT.md index 3298e2a..c013370 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,11 +1,41 @@ # PRAutoBlogger — Domain Glossary -Quick reference for the generation pipeline domain model. For full design rationale -see `ARCHITECTURE.md` (§21–§25) and `CONVENTIONS.md`. +Per DoD v1.2.0 §7, this file defines domain terms introduced by structural changes. +Update when a PR introduces new terms not covered here. + +For full design rationale see `ARCHITECTURE.md` (§21–§25) and `CONVENTIONS.md`. --- -## Core Domain Terms +## Pipeline Settings (introduced v0.23.0) + +| Term | Definition | +|------|-----------| +| **step** | One LLM-powered stage in the article-generation pipeline (Research, Analysis, Writer, Editorial, Image). Non-LLM stages (Scoring, Publish) are not surfaced here. | +| **step rail** | The vertical navigation column in the Pipeline Settings page; one button per step. | +| **step panel** | The right-hand content area in the Pipeline Settings page that shows model, params, and prompts for the selected step. | +| **pipeline-ui** | The author tag written to the prompts table when an operator edits a prompt via the Pipeline Settings page (e.g. `pipeline-ui:rhys`). | +| **system instruction** | The system prompt for an LLM step — sets role, constraints, and style for the model. Stored in the prompt registry under a key like `research.system`. | +| **agent prompt** | A user-turn prompt for an LLM step — contains the task the model must perform. Stored under keys like `content.single_pass`, `content.draft`. | +| **system_key** | The registry key string for a step's system instruction (e.g. `research.system`). Defined in `PRAutoBlogger_Pipeline_Settings_Step_Map`. | +| **agent_key** | A registry key string for one of a step's agent/user prompts. A step may have zero or more agent keys. | +| **prompt key** | Generic term covering both system_key and agent_key — a dotted string like `content.single_pass` that uniquely identifies a prompt in the registry. | +| **prompt slug** | URL/form-safe encoding of a prompt key: dots replaced with hyphens (e.g. `content-single_pass`). Used as HTML field values and `?prompt_key=` query params. | +| **reset-to-default** | A Pipeline Settings action that creates a new prompt version with the canonical seed body (`PRAutoBlogger_Prompt_Registry::default_body()`), effectively restoring factory settings for that key. | +| **step map** | `PRAutoBlogger_Pipeline_Settings_Step_Map` — the single source of truth for step metadata, model option keys, and prompt key allowlists. | +| **save handler** | `PRAutoBlogger_Pipeline_Settings_Save_Handler` — stateless class that verifies nonce/cap, validates against allowlists, and persists model or prompt changes. | + +## Prompt Registry Terms (introduced v0.16.0–v0.18.0) + +| Term | Definition | +|------|-----------| +| **prompt version** | An immutable row in `wp_prautoblogger_prompts`. Once written, the body never changes; a new edit creates a new version row. | +| **active version** | The single version per prompt key flagged `is_active = 1`. Older versions are retained for history/rollback. | +| **seed** | Version 1 of a prompt key, written by the activator migration (`seed_v1()`). Author tag: `seed`. | + +--- + +## Core Domain Terms (introduced v0.22.1) **run** A single end-to-end article generation cycle: orchestrate → generate idea(s) → @@ -33,7 +63,7 @@ DB mutex that prevents two concurrent runs. Acquired at the start of --- -## Execution Modes +## Execution Modes (introduced v0.22.1) ### Async (wp-cron driven) diff --git a/CONVENTIONS.md b/CONVENTIONS.md index f1ae518..1fae5c2 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -572,3 +572,119 @@ public function on_enqueue_assets( string $hook_suffix ): void { // ... enqueue } ``` + +--- + +## How To: Add a New Pipeline-Style Admin Page + +Use this pattern when a page needs per-step or multi-section rendering with +a separate save-handler (i.e. when the page is too complex for a single-class +approach). The Pipeline Settings page (`prautoblogger-pipeline`) is the +canonical reference. + +### Four-class split + +| Class | File | Responsibility | +|-------|------|---------------| +| `PRAutoBlogger_{Name}_Page` | `class-{name}-page.php` | Registration, asset enqueue, render entry-point. Delegates immediately. | +| `PRAutoBlogger_{Name}_Renderer` | `class-{name}-renderer.php` | Gathers ALL view data (options, spend, computed values). Passes a typed `$view` array to the template. No raw echo; no DB writes. | +| `PRAutoBlogger_{Name}_Save_Handler` | `class-{name}-save-handler.php` | Stateless. Verifies nonce + capability, sanitises input against an allowlist, persists. Returns `{status, message}`. Called before output starts. | +| `PRAutoBlogger_{Name}_Step_Map` | `class-{name}-step-map.php` | Single source of truth for step metadata and key allowlists. Read by both Renderer and Save_Handler. | + +### Registration priority + +The page submenu must register AFTER the parent menu. Convention: +- Parent menu: priority 10 (`PRAutoBlogger_Admin_Page`) +- Board: priority 11 +- Dossier: priority 12 +- Pipeline: priority 13 +- Next pipeline-style page: priority 14+ + +### Renderer contract (non-negotiable) + +The renderer must gather **all** data the template needs — including service +objects like `PRAutoBlogger_Cost_Reporter` — so the template receives only +plain scalars and arrays: + +```php +// In Renderer::render(): +$cost_reporter = new PRAutoBlogger_Cost_Reporter(); +$view['monthly_spend'] = $cost_reporter->get_monthly_spend(); +$view['budget'] = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 ); +``` + +**Never** instantiate service objects inside a template. The template must be +a logic-free view layer. + +### Save-handler model-field POST key + +`PRAutoBlogger_OpenRouter_Model_Field::render($id, ...)` emits: +```html + +``` +`model-picker.js` writes the selected model to that input **by DOM id**, so +the form posts the value under the option name, not a separate `model_id` key. +In the save handler, read: +```php +$model_id = isset( $_POST[ $option_name ] ) + ? sanitize_text_field( wp_unslash( $_POST[ $option_name ] ) ) + : ''; +``` +Do **not** read `$_POST['model_id']` — that key is never present. + +### Nonce / capability pattern + +```php +// 1. Check method (bail silently if not POST or nonce field absent). +if ( 'POST' !== $_SERVER['REQUEST_METHOD'] ) { return $idle; } +if ( empty( $_POST[ NONCE_FIELD ] ) ) { return $idle; } + +// 2. Capability BEFORE nonce (avoids timing oracle for unauthenticated users). +if ( ! current_user_can( 'manage_options' ) ) { return error; } + +// 3. Verify nonce. +if ( ! wp_verify_nonce( ... ) ) { return error; } +``` + +### Allowlist enforcement + +All POST keys that map to DB writes (model option names, prompt keys) must be +validated against a static allowlist before use. Define the allowlist on +`Step_Map` and call it from the save handler: + +```php +if ( ! in_array( $option_name, PRAutoBlogger_{Name}_Step_Map::allowed_model_options(), true ) ) { + return array( 'status' => 'error', 'message' => __( 'Unknown model option.', 'prautoblogger' ) ); +} +``` + +### Prompt key slug round-trip + +Form fields carry dots as hyphens (`content.single_pass` → `content-single_pass`) +because HTML name attributes cannot contain dots in some contexts. +`sanitize_key()` preserves underscores, so the slug round-trip is unambiguous — no two +keys produce the same slug. Use `sanitize_key( str_replace( '.', '-', $key ) )` to compare, +not string parsing: + +```php +private static function resolve_key_from_slug( string $slug ): ?string { + foreach ( Step_Map::allowed_prompt_keys() as $key ) { + if ( sanitize_key( str_replace( '.', '-', $key ) ) === $slug ) { + return $key; + } + } + return null; +} +``` + +### Tests + +Every pipeline-style page must have PHPUnit coverage for: +- `Save_Handler`: allowlist enforcement (valid + invalid keys/options), correct + option persistence, prompt versioning (`create_version()` called with correct + key and `$activate = true`), `resolve_key_from_slug()` round-trip. +- `Step_Map`: `allowed_prompt_keys()` and `allowed_model_options()` regression + guards so keys do not drift from the registry. + +Place tests under `tests/unit/Admin/PipelineSettings*Test.php`, extending +`PRAutoBlogger\Tests\BaseTestCase`. diff --git a/INFORMATION-ARCHITECTURE.md b/INFORMATION-ARCHITECTURE.md index d8f9fc6..e532965 100644 --- a/INFORMATION-ARCHITECTURE.md +++ b/INFORMATION-ARCHITECTURE.md @@ -11,6 +11,7 @@ Maintained per DoD v1.1.0 (same-PR update rule). |------|-------|----------|---------| | `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings (schedule, models, budget, board limits) | | `prautoblogger-board` | `PRAutoBlogger_Board_Page` | `board-page.php` | Kanban board: Generating / Failed / In Review / Published columns. New Article button (v0.21.0). | +| `prautoblogger-pipeline` | `PRAutoBlogger_Pipeline_Settings_Page` | `pipeline-settings-page.php` | Per-step pipeline config: model picker, system instructions, agent prompts, params (M1, v0.23.0). | | `prautoblogger-articles` | `PRAutoBlogger_Articles_Page` | `articles-page.php` | Paginated list of all articles/runs | | `prautoblogger-dossier` | `PRAutoBlogger_Dossier_Page` | `dossier-page.php` | Per-article generation log + stage edit/re-run (M3) | | `prautoblogger-ideas` | `PRAutoBlogger_Ideas_Browser` | `ideas-browser.php` | Analysis results browser with per-idea generation | diff --git a/assets/css/pipeline-settings.css b/assets/css/pipeline-settings.css new file mode 100644 index 0000000..02d4b88 --- /dev/null +++ b/assets/css/pipeline-settings.css @@ -0,0 +1,181 @@ +/** + * Pipeline Settings page styles. + * + * Design language: light mode, teal #1B8A92 accent, IBM Plex Mono for + * numerics/code, wp-admin card shadow pattern. Extends admin.css. + * + * @see admin/class-pipeline-settings-page.php — Enqueues this file. + */ + +/* ── Layout ── */ +.pab-pipeline-wrap { max-width: 1200px; } + +.pab-pipeline-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 0; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 6px; + box-shadow: 0 1px 4px rgba(0,0,0,.06); + margin-top: 16px; + overflow: hidden; +} + +/* ── Step rail ── */ +.pab-step-rail { + background: #f7f9fa; + border-right: 1px solid #e0e0e0; + padding: 8px 0; +} + +.pab-step-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + text-decoration: none; + color: #3c434a; + font-size: 13px; + border-left: 3px solid transparent; + transition: background .12s, border-color .12s; +} +.pab-step-btn:hover { + background: #edf5f6; + color: #1B8A92; + border-left-color: #a8d8db; +} +.pab-step-btn--active { + background: #e6f4f5; + color: #1B8A92; + font-weight: 600; + border-left-color: #1B8A92; +} +.pab-step-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: #1B8A92; + color: #fff; + border-radius: 50%; + font-size: 11px; + font-family: 'IBM Plex Mono', 'Courier New', monospace; + flex-shrink: 0; +} +.pab-step-btn:not(.pab-step-btn--active) .pab-step-num { + background: #c3cdd4; +} +.pab-step-icon { color: inherit; font-size: 16px !important; flex-shrink: 0; } +.pab-step-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* ── Step panel ── */ +.pab-step-panel { + padding: 24px 28px; + overflow-y: auto; +} + +.pab-panel-header { margin-bottom: 20px; border-bottom: 1px solid #e8ecef; padding-bottom: 14px; } +.pab-panel-header h2 { margin: 0 0 4px; font-size: 18px; color: #1d2327; } +.pab-panel-desc { margin: 4px 0 0; color: #646970; font-size: 13px; } + +/* ── Sections ── */ +.pab-section { margin-bottom: 28px; } +.pab-section:not(:last-child) { border-bottom: 1px solid #f0f0f1; padding-bottom: 24px; } +.pab-section-title { + font-size: 14px; + font-weight: 600; + color: #1d2327; + margin: 0 0 10px; + display: flex; + align-items: center; + gap: 8px; +} + +/* ── Model picker section ── */ +.pab-model-picker-wrap { margin-bottom: 10px; } +.pab-model-form { max-width: 520px; } + +/* ── Prompt editors ── */ +.pab-prompt-editor { + width: 100%; + font-family: 'IBM Plex Mono', 'Courier New', monospace; + font-size: 12px; + line-height: 1.6; + background: #fafbfc; + border: 1px solid #d0d5dd; + border-radius: 4px; + padding: 10px 12px; + resize: vertical; + box-sizing: border-box; + color: #1d2327; +} +.pab-prompt-editor:focus { + border-color: #1B8A92; + box-shadow: 0 0 0 2px rgba(27,138,146,.12); + outline: none; +} +.pab-prompt-form { max-width: 820px; } +.pab-prompt-actions { display: flex; gap: 8px; margin-top: 8px; align-items: center; } +.pab-btn-reset { color: #9b1c1c; border-color: #f5c6c6; } +.pab-btn-reset:hover { background: #fef2f2; border-color: #e53e3e; color: #9b1c1c; } + +/* ── Params table ── */ +.pab-params-table { border-collapse: collapse; font-size: 13px; } +.pab-params-table th, .pab-params-table td { text-align: left; padding: 4px 12px 4px 0; vertical-align: top; } +.pab-params-table th { color: #646970; font-weight: 500; min-width: 140px; } +.pab-mono { + font-family: 'IBM Plex Mono', 'Courier New', monospace; + font-size: 12px; + background: #f3f4f6; + padding: 2px 6px; + border-radius: 3px; +} + +/* ── Badges ── */ +.pab-version-badge { + display: inline-flex; + align-items: center; + background: #e6f4f5; + color: #1B8A92; + font-size: 11px; + font-family: 'IBM Plex Mono', 'Courier New', monospace; + font-weight: 600; + padding: 1px 6px; + border-radius: 10px; + border: 1px solid #b2e0e3; +} +.pab-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; } +.pab-badge-ok { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; } +.pab-badge-warn { background: #fffbeb; color: #92400e; border: 1px solid #fcd34d; } + +/* ── Global context note ── */ +.pab-global-context-note { + display: flex; + align-items: center; + gap: 8px; + background: #f0f9fa; + border: 1px solid #b2e0e3; + border-radius: 4px; + padding: 8px 14px; + margin: 12px 0 0; + font-size: 13px; + color: #1d4f52; +} + +/* ── Misc ── */ +.pab-field-note { color: #646970; font-size: 12px; margin: 6px 0 10px; } +.pab-version-meta { color: #646970; font-size: 12px; margin: -4px 0 8px; } +.pab-inactive-note { + display: flex; + align-items: center; + gap: 6px; + color: #a18a2f; + background: #fffde7; + border: 1px solid #ffe082; + border-radius: 4px; + padding: 6px 12px; + font-size: 12px; + margin-bottom: 8px; +} diff --git a/assets/js/pipeline-settings.js b/assets/js/pipeline-settings.js new file mode 100644 index 0000000..cad1bbc --- /dev/null +++ b/assets/js/pipeline-settings.js @@ -0,0 +1,24 @@ +/** + * Pipeline Settings page — step rail and prompt editor interactions. + * + * Responsibilities: + * - Confirm before resetting a prompt to default (redundant safety; the + * PHP onclick already handles it, but JS enhances UX). + * + * Note: model selection is handled entirely by model-picker.js, which writes + * the chosen model id directly to the hidden input named after the option + * (e.g. name="prautoblogger_research_model"). No event bridge is needed here — + * the form submits that value on the standard POST to handle_model_save(). + * + * @see admin/class-pipeline-settings-save-handler.php — Reads $_POST[$option_name]. + * @see assets/js/model-picker.js — Writes to the hidden input by DOM id. + */ +/* global jQuery */ +( function ( $ ) { + 'use strict'; + + $( function () { + // Future UX hooks (e.g. unsaved-changes warning) go here. + } ); + +}( jQuery ) ); diff --git a/includes/admin/class-pipeline-settings-page.php b/includes/admin/class-pipeline-settings-page.php new file mode 100644 index 0000000..86f9768 --- /dev/null +++ b/includes/admin/class-pipeline-settings-page.php @@ -0,0 +1,112 @@ + admin_url( 'admin-ajax.php' ), + 'adminUrl' => admin_url(), + 'siteUrl' => home_url( '/' ), + 'modelsNonce' => wp_create_nonce( 'prautoblogger_get_models' ), + 'imageModels' => PRAutoBlogger_Settings_Fields_Extended::get_image_models(), + ) + ); + } + + /** + * Render the Pipeline Settings page. + * + * Capability check first; then delegate to the save handler (processes + * POST before output starts) and the renderer (assembles view data and + * includes the template). + * + * @return void + */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to access this page.', 'prautoblogger' ) ); + } + + // Process any pending save before output begins. + $save_result = PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save(); + + $renderer = new PRAutoBlogger_Pipeline_Settings_Renderer(); + $renderer->render( $save_result ); + } +} diff --git a/includes/admin/class-pipeline-settings-renderer.php b/includes/admin/class-pipeline-settings-renderer.php new file mode 100644 index 0000000..62fe5a7 --- /dev/null +++ b/includes/admin/class-pipeline-settings-renderer.php @@ -0,0 +1,148 @@ +get_monthly_spend(); + $budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 ); + + $view = array( + 'steps' => $steps, + 'active_step' => $step, + 'save_result' => $save_result, + 'nonce_field' => PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD, + 'nonce_action' => PRAutoBlogger_Pipeline_Settings_Page::NONCE_ACTION, + 'page_slug' => PRAutoBlogger_Pipeline_Settings_Page::PAGE_SLUG, + 'step_data' => $this->build_step_data( $step ), + 'monthly_spend' => $monthly_spend, + 'budget' => $budget, + ); + + include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/pipeline-settings-page.php'; + } + + /** + * Assemble all data needed to render a single step panel. + * + * @param array $step Step definition from Step_Map::steps(). + * @return array View data for the step panel. + */ + public function build_step_data( array $step ): array { + $defs = PRAutoBlogger_Prompt_Registry::defs(); + $active_versions_map = PRAutoBlogger_Prompt_Registry::active_versions(); + + // Model info. + $model_option = (string) ( $step['model_option'] ?? '' ); + $model_value = '' !== $model_option ? (string) get_option( $model_option, '' ) : ''; + + // System prompt. + $system_key = (string) ( $step['system_key'] ?? '' ); + $system_data = $this->build_prompt_panel_data( $system_key, $defs, $active_versions_map ); + + // Agent/user prompts. + $agent_panels = array(); + foreach ( ( $step['agent_keys'] ?? array() ) as $key ) { + $agent_panels[ $key ] = $this->build_prompt_panel_data( $key, $defs, $active_versions_map ); + } + + // Params from the def for the system key (canonical params are on system). + $params = isset( $defs[ $system_key ] ) ? ( $defs[ $system_key ]['params'] ?? array() ) : array(); + + return array( + 'step' => $step, + 'model_option' => $model_option, + 'model_value' => $model_value, + 'system' => $system_data, + 'agent_panels' => $agent_panels, + 'params' => $params, + ); + } + + /** + * Build display data for a single prompt key's edit panel. + * + * @param string $key Registry key. + * @param array}> $defs All defs. + * @param array $active_versions_map key => active version. + * @return array + */ + private function build_prompt_panel_data( string $key, array $defs, array $active_versions_map ): array { + if ( '' === $key ) { + return array(); + } + + $active_row = PRAutoBlogger_Prompt_Registry::get_active( $key ); + $active_version = $active_versions_map[ $key ] ?? 0; + $body = null !== $active_row ? (string) $active_row['body'] : (string) PRAutoBlogger_Prompt_Registry::default_body( $key ); + $default_body = (string) PRAutoBlogger_Prompt_Registry::default_body( $key ); + $is_default = ( $body === $default_body ); + $created_at = null !== $active_row ? (string) $active_row['created_at'] : ''; + $author = null !== $active_row ? (string) $active_row['author'] : 'seed'; + + $versions = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $key ); + + return array( + 'key' => $key, + 'body' => $body, + 'default_body' => $default_body, + 'is_default' => $is_default, + 'active_version' => $active_version, + 'created_at' => $created_at, + 'author' => $author, + 'version_count' => count( $versions ), + ); + } + + /** + * Render the model picker for a step using the existing field component. + * + * Public so the template can call it directly. Keeps markup generation + * in one place and avoids duplicating the picker rendering logic. + * + * @param string $model_option wp_option name (e.g. 'prautoblogger_writing_model'). + * @param string $model_value Current model id stored in the option. + * @param array $field_args Field args forwarded to the picker. + * @return void Side effect: outputs HTML. + */ + public function render_model_picker( string $model_option, string $model_value, array $field_args ): void { + PRAutoBlogger_OpenRouter_Model_Field::render( $model_option, $model_value, $field_args ); + } +} diff --git a/includes/admin/class-pipeline-settings-save-handler.php b/includes/admin/class-pipeline-settings-save-handler.php new file mode 100644 index 0000000..8c42d61 --- /dev/null +++ b/includes/admin/class-pipeline-settings-save-handler.php @@ -0,0 +1,233 @@ + 'idle', + 'message' => '', + ); + + if ( 'POST' !== $_SERVER['REQUEST_METHOD'] ) { + return $idle; + } + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( empty( $_POST[ PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD ] ) ) { + return $idle; + } + + // Capability check. + if ( ! current_user_can( 'manage_options' ) ) { + return array( + 'status' => 'error', + 'message' => __( 'You do not have permission to save settings.', 'prautoblogger' ), + ); + } + + // Nonce verification. + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + if ( ! wp_verify_nonce( wp_unslash( $_POST[ PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD ] ), PRAutoBlogger_Pipeline_Settings_Page::NONCE_ACTION ) ) { + return array( + 'status' => 'error', + 'message' => __( 'Security check failed. Please reload and try again.', 'prautoblogger' ), + ); + } + + $action = isset( $_POST['pipeline_action'] ) ? sanitize_key( $_POST['pipeline_action'] ) : ''; + + if ( 'save_model' === $action ) { + return self::handle_model_save(); + } + if ( 'save_prompt' === $action || 'reset_prompt' === $action ) { + return self::handle_prompt_save( $action ); + } + + return $idle; + } + + /** + * Persist a model selection for one step. + * + * The model value is read from $_POST[$option_name] — the same key that + * PRAutoBlogger_OpenRouter_Model_Field::render($option_name, ...) emits as + * . model-picker.js writes to + * that hidden input directly via DOM id, so the value is present under the + * option-name key in the submitted POST body. + * + * Only allowlisted model option names from Step_Map may be updated. + * + * @return array{status: string, message: string} + */ + private static function handle_model_save(): array { + $option_name = isset( $_POST['model_option'] ) ? sanitize_key( $_POST['model_option'] ) : ''; + + if ( ! in_array( $option_name, PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_model_options(), true ) ) { + return array( + 'status' => 'error', + 'message' => __( 'Unknown model option.', 'prautoblogger' ), + ); + } + + // Read the value from the named hidden input that render() produced. + // PRAutoBlogger_OpenRouter_Model_Field::render($id, ...) emits: + // + // model-picker.js writes the chosen model to that input by DOM id, + // so the form posts under the option name, not a separate 'model_id' key. + $model_id = isset( $_POST[ $option_name ] ) + ? sanitize_text_field( wp_unslash( $_POST[ $option_name ] ) ) + : ''; + + // For the image model, delegate to the same sanitizer logic as + // PRAutoBlogger_Settings_Sanitizer to derive + store provider consistently. + if ( 'prautoblogger_image_model' === $option_name ) { + $candidate = sanitize_text_field( $model_id ); + $provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate ); + if ( '' !== $provider ) { + update_option( 'prautoblogger_image_provider', $provider ); + update_option( $option_name, $candidate ); + return array( + 'status' => 'saved', + 'message' => __( 'Model saved.', 'prautoblogger' ), + ); + } + return array( + 'status' => 'error', + 'message' => __( 'Image model not recognised. Selection unchanged.', 'prautoblogger' ), + ); + } + + update_option( $option_name, sanitize_text_field( $model_id ) ); + return array( + 'status' => 'saved', + 'message' => __( 'Model saved.', 'prautoblogger' ), + ); + } + + /** + * Create a new prompt version (or reset to default) in the registry. + * + * The prompt key is received as a slug (dots replaced with hyphens in the + * form value). We match the slug against the allowlist by resolving each + * allowed key to its slug form — this avoids any reverse-parsing ambiguity + * for keys that contain underscores (e.g. content.single_pass). + * + * Prompt bodies are treated as privileged — only sanitize_textarea_field() + * is applied to strip any script injection, but content is not further + * mangled — the operator is intentionally writing LLM instructions. + * + * @param string $action 'save_prompt' or 'reset_prompt'. + * @return array{status: string, message: string} + */ + private static function handle_prompt_save( string $action ): array { + // The form sends the key with dots replaced by hyphens (HTML-safe slug). + $slug = isset( $_POST['prompt_key'] ) ? sanitize_key( wp_unslash( $_POST['prompt_key'] ) ) : ''; + + // Resolve slug back to the real registry key via allowlist lookup. + $prompt_key = self::resolve_key_from_slug( $slug ); + if ( null === $prompt_key ) { + return array( + 'status' => 'error', + 'message' => __( 'Unknown prompt key.', 'prautoblogger' ), + ); + } + + if ( 'reset_prompt' === $action ) { + $body = PRAutoBlogger_Prompt_Registry::default_body( $prompt_key ); + if ( null === $body ) { + return array( + 'status' => 'error', + 'message' => __( 'No default found for this prompt key.', 'prautoblogger' ), + ); + } + $author = 'reset:pipeline-ui'; + } else { + $raw_body = isset( $_POST['prompt_body'] ) ? wp_unslash( $_POST['prompt_body'] ) : ''; + $body = sanitize_textarea_field( $raw_body ); + $author = 'pipeline-ui:' . ( wp_get_current_user()->user_login ?? 'admin' ); + } + + $version = PRAutoBlogger_Prompt_Registry_Writer::create_version( + $prompt_key, + $body, + null, + null, + $author, + true + ); + + if ( 0 === $version ) { + return array( + 'status' => 'error', + 'message' => __( 'Failed to save prompt. Is the prompts table available?', 'prautoblogger' ), + ); + } + + return array( + 'status' => 'saved', + // translators: %1$s = prompt key, %2$d = new version number. + 'message' => sprintf( __( 'Saved %1$s as version %2$d.', 'prautoblogger' ), esc_html( $prompt_key ), $version ), + ); + } + + /** + * Resolve a URL/form slug back to the canonical registry key. + * + * Each allowed key is converted to a slug (dots → hyphens, everything + * lowercased by sanitize_key) and compared against the submitted value. + * No string-reverse parsing — only allowlist membership determines a match. + * + * @param string $slug Slug as received from the form (after sanitize_key). + * @return string|null Canonical key, or null when not in the allowlist. + */ + private static function resolve_key_from_slug( string $slug ): ?string { + foreach ( PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_prompt_keys() as $key ) { + if ( sanitize_key( str_replace( '.', '-', $key ) ) === $slug ) { + return $key; + } + } + return null; + } +} diff --git a/includes/admin/class-pipeline-settings-step-map.php b/includes/admin/class-pipeline-settings-step-map.php new file mode 100644 index 0000000..23c919b --- /dev/null +++ b/includes/admin/class-pipeline-settings-step-map.php @@ -0,0 +1,142 @@ +> + */ + public static function steps(): array { + return array( + array( + 'id' => 'research', + 'label' => __( 'Research', 'prautoblogger' ), + 'icon' => 'dashicons-search', + 'model_option' => 'prautoblogger_research_model', + 'capability' => 'text→text', + 'system_key' => 'research.system', + 'agent_keys' => array(), + 'description' => __( 'LLM deep-research call. The user-side research brief is a setting below, not a registry prompt.', 'prautoblogger' ), + ), + array( + 'id' => 'analysis', + 'label' => __( 'Analysis', 'prautoblogger' ), + 'icon' => 'dashicons-chart-bar', + 'model_option' => 'prautoblogger_analysis_model', + 'capability' => 'text→text', + 'system_key' => 'analysis.system', + 'agent_keys' => array( 'analysis.user' ), + 'description' => __( 'Scores and selects ideas from research findings.', 'prautoblogger' ), + ), + array( + 'id' => 'writer', + 'label' => __( 'Writer', 'prautoblogger' ), + 'icon' => 'dashicons-edit', + 'model_option' => 'prautoblogger_writing_model', + 'capability' => 'text→text', + 'system_key' => 'content.system', + 'agent_keys' => array( 'content.single_pass', 'content.outline', 'content.draft', 'content.polish' ), + 'description' => __( 'Generates article content. Active sub-stages depend on the Writing Pipeline mode setting.', 'prautoblogger' ), + ), + array( + 'id' => 'editorial', + 'label' => __( 'Editorial', 'prautoblogger' ), + 'icon' => 'dashicons-admin-comments', + 'model_option' => 'prautoblogger_editor_model', + 'capability' => 'text→text', + 'system_key' => 'editor.system', + 'agent_keys' => array( 'editor.review' ), + 'description' => __( 'Single-pass editorial review — approves, revises, or rejects the draft.', 'prautoblogger' ), + ), + array( + 'id' => 'image', + 'label' => __( 'Image', 'prautoblogger' ), + 'icon' => 'dashicons-format-image', + 'model_option' => 'prautoblogger_image_model', + 'capability' => 'image_generation', + 'system_key' => 'image.rewriter_system', + 'agent_keys' => array( 'image.style_template' ), + 'description' => __( 'Image prompt rewriter + diffusion generation. The rewriter LLM uses the Analysis model.', 'prautoblogger' ), + ), + ); + } + + /** + * Return a single step definition by its id, or null when not found. + * + * @param string $step_id Machine identifier of the step. + * @return array|null + */ + public static function find( string $step_id ): ?array { + foreach ( self::steps() as $step ) { + if ( $step['id'] === $step_id ) { + return $step; + } + } + return null; + } + + /** + * All registry keys that the save handler is permitted to update via + * the Pipeline Settings page. Any submitted key not in this list is rejected. + * + * @return string[] + */ + public static function allowed_prompt_keys(): array { + $keys = array(); + foreach ( self::steps() as $step ) { + if ( ! empty( $step['system_key'] ) ) { + $keys[] = $step['system_key']; + } + foreach ( ( $step['agent_keys'] ?? array() ) as $k ) { + $keys[] = $k; + } + } + return array_unique( $keys ); + } + + /** + * All model option names that the save handler may update. + * + * @return string[] + */ + public static function allowed_model_options(): array { + $opts = array(); + foreach ( self::steps() as $step ) { + if ( ! empty( $step['model_option'] ) ) { + $opts[] = $step['model_option']; + } + } + return array_unique( $opts ); + } +} diff --git a/includes/admin/fields/class-open-router-model-field.php b/includes/admin/fields/class-open-router-model-field.php index 7b71b76..f716acf 100644 --- a/includes/admin/fields/class-open-router-model-field.php +++ b/includes/admin/fields/class-open-router-model-field.php @@ -9,12 +9,19 @@ declare(strict_types=1); * What: a button showing the current model's name + pricing. Clicking it * opens a JS-powered popup (model-picker.js) with a searchable list. * Includes an estimated cost preview below based on historical usage. + * For image-generation steps the static cost-per-image price is shown + * in the trigger button; the per-generation cost-preview panel is + * intentionally omitted (image cost is a fixed $/image, not token- + * based, so the historical-token preview formula does not apply). * Who calls it: PRAutoBlogger_Admin_Page::render_field() delegates here - * for fields with type 'model_select'. + * for fields with type 'model_select', and + * PRAutoBlogger_Pipeline_Settings_Renderer::render_model_picker() + * for the Pipeline Settings page. * Dependencies: PRAutoBlogger_Model_Registry_Interface (cached registry lookup), * PRAutoBlogger_Cost_Tracker (historical token averages). * * @see admin/class-admin-page.php — Calls render() from the main field switch. + * @see admin/class-pipeline-settings-renderer.php — Calls render_model_picker(). * @see assets/js/model-picker.js — JS popup that fetches + displays models. * @see services/interface-model-registry.php — Registry interface for find_model(). * @see core/class-cost-tracker.php — get_avg_tokens_for_stages() for cost preview. @@ -55,6 +62,9 @@ class PRAutoBlogger_OpenRouter_Model_Field { } // Hidden input carries the actual value for the form submission. + // The name attribute equals $id so that PRAutoBlogger_Pipeline_Settings_Save_Handler + // can read $_POST[$option_name] directly — model-picker.js writes the selected + // model id to this input by DOM id, and the browser posts it under this name. printf( '', esc_attr( $id ), @@ -75,7 +85,9 @@ class PRAutoBlogger_OpenRouter_Model_Field { esc_html( $display_price ) ); - // Cost preview panel — only for text→text models (not image). + // Per-generation cost-preview panel — text→text models only. + // Image steps show a static $/image price in the trigger button above; + // the token-based historical cost formula does not apply to image generation. if ( 'image_generation' !== $capability && '' !== $value ) { $cost_preview = self::get_cost_preview( $id, $value ); if ( $cost_preview ) { @@ -130,7 +142,10 @@ class PRAutoBlogger_OpenRouter_Model_Field { } /** - * Map a setting ID to its constituent pipeline stages. + * Map a text→text setting ID to its constituent pipeline stages for the + * cost-preview calculation. Image-generation settings are not listed here + * because the cost preview is intentionally skipped for image steps + * (static $/image price is shown in the picker trigger instead). * * @param string $field_id Setting ID. * @@ -141,6 +156,9 @@ class PRAutoBlogger_OpenRouter_Model_Field { 'prautoblogger_analysis_model' => array( 'analysis' ), 'prautoblogger_writing_model' => array( 'outline', 'draft', 'polish' ), 'prautoblogger_editor_model' => array( 'review' ), + 'prautoblogger_research_model' => array( 'research', 'llm_research' ), + // prautoblogger_image_model is intentionally absent: image cost is a + // fixed $/image (shown in the trigger button), not a token-based preview. ); return $map[ $field_id ] ?? array(); } diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index ca74d4f..a945907 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -72,6 +72,11 @@ class PRAutoBlogger { add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 ); add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) ); + // Pipeline Settings: visible submenu, priority 13 (after dossier). + $pipeline_page = new PRAutoBlogger_Pipeline_Settings_Page(); + add_action( 'admin_menu', array( $pipeline_page, 'on_register_menu' ), 13 ); + add_action( 'admin_enqueue_scripts', array( $pipeline_page, 'on_enqueue_assets' ) ); + // Dossier: link-accessed hidden submenu, priority 12 (after board). $dossier_page = new PRAutoBlogger_Dossier_Page(); add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 ); diff --git a/prautoblogger.php b/prautoblogger.php index 0190ff0..2cd6066 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.22.1 + * Version: 0.23.0 * 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.22.1' ); +define( 'PRAUTOBLOGGER_VERSION', '0.23.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 ); define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 ); diff --git a/templates/admin/pipeline-settings-page.php b/templates/admin/pipeline-settings-page.php new file mode 100644 index 0000000..4df5db7 --- /dev/null +++ b/templates/admin/pipeline-settings-page.php @@ -0,0 +1,198 @@ + +
+ +
+
+ +
+

+ v +
+
+
+ + + +
+
+ +
+
+ + $ / $ +
+
+ + + + + + + + +
+
+ + +
+ +
+ +
+ +
+ + + +
+ + + + + + +
+ +
+ + + +
+ +
+

+ +

+ +
+ + +
+

+
+ + + +
+ render_model_picker( + $step_data['model_option'], + $step_data['model_value'], + array( 'capability' => $active_step['capability'] ?? 'text→text' ) + ); + ?> +
+

+ +

+ +
+
+ + + +
+

+ + $param_value ) : ?> + + + + + +
+

+ +

+
+ + + + + $panel ) : + $panel_title = sprintf( + /* translators: %s = registry key e.g. 'content.draft' */ + __( 'Prompt: %s', 'prautoblogger' ), + $key + ); + $step_id = $active_step['id']; + $is_writer = ( 'writer' === $step_id ); + $writing_mode = (string) get_option( 'prautoblogger_writing_pipeline', 'multi_step' ); + include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/pipeline-settings-prompt-panel.php'; + endforeach; + ?> + +
+
+
diff --git a/templates/admin/pipeline-settings-prompt-panel.php b/templates/admin/pipeline-settings-prompt-panel.php new file mode 100644 index 0000000..fdb803c --- /dev/null +++ b/templates/admin/pipeline-settings-prompt-panel.php @@ -0,0 +1,107 @@ + +
+

+ + + 0 ) { + printf( esc_html__( 'v%d', 'prautoblogger' ), (int) $panel['active_version'] ); + } else { + esc_html_e( 'default', 'prautoblogger' ); + } + ?> + + + + +

+ + 0 ) : ?> +

+ +

+ + + +

+ + +

+ + +
+ + + + + + +
+ + + + +
+
+
diff --git a/tests/unit/Admin/PipelineSettingsSaveHandlerTest.php b/tests/unit/Admin/PipelineSettingsSaveHandlerTest.php new file mode 100644 index 0000000..e7bd38b --- /dev/null +++ b/tests/unit/Admin/PipelineSettingsSaveHandlerTest.php @@ -0,0 +1,251 @@ + Captured update_option() calls: option_name => value. */ + private array $saved_options = array(); + + /** @var array> 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 ); + } +} diff --git a/tests/unit/Admin/PipelineSettingsStepMapTest.php b/tests/unit/Admin/PipelineSettingsStepMapTest.php new file mode 100644 index 0000000..edcbc91 --- /dev/null +++ b/tests/unit/Admin/PipelineSettingsStepMapTest.php @@ -0,0 +1,169 @@ +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' ) ); + } +}