diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cba6123..dd4b25c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,14 +1,15 @@ # Architecture — PR Vision -**Version:** 0.1.1 | **Last updated:** 2026-06-13 +**Version:** 0.2.0 | **Last updated:** 2026-06-14 --- ## 1. Overview -`pr-vision` is an internal WordPress plugin that runs weekly server-side LLM probes to measure whether peptiderepo.com appears in AI citations across core peptide queries. It stores a time-series in a custom table and renders an admin dashboard. +`pr-vision` is an internal WordPress plugin that runs configurable server-side LLM probes to measure whether peptiderepo.com appears in AI citations across core peptide queries. It stores a time-series in a custom table and renders an admin dashboard (trendline + standings) and a full Settings page (model manager + probe config). -**v1 scope:** AI-visibility only. The collector/panel seam is present so future data categories (keyword rankings, technical-SEO) can be added as new classes. +**v1 scope:** AI-visibility only. The collector/panel seam is present so future data categories can be added as new classes. +**v2 additions:** Model manager CRUD (no deploy needed), config-version tracking, run-lock, projected cost, API-key three-state, dark "Assay" Settings UI. --- @@ -17,23 +18,31 @@ ``` pr-vision/ ├── pr-vision.php # Plugin boot: constants, autoloader, hooks -├── uninstall.php # Full data teardown: DROP TABLE + DELETE prv_* options -├── composer.json # Dev deps (PHPCS only) -├── phpcs.xml.dist # PHPCS config (WordPress standard) +├── uninstall.php # Full data teardown: DROP TABLE + DELETE prv_* options + transients +├── composer.json # Dev deps (PHPCS only) +├── phpcs.xml.dist # PHPCS config (WordPress standard) ├── includes/ │ ├── core/ -│ │ ├── class-prv-autoloader.php # SPL autoloader: PRV_* → includes/** -│ │ ├── class-prv-plugin.php # Orchestrator: boots sub-systems +│ │ ├── class-prv-autoloader.php # SPL autoloader: PRV_* -> includes/** +│ │ ├── class-prv-plugin.php # Orchestrator: boots sub-systems + upgrader +│ │ ├── class-prv-upgrader.php # [v0.2] Runs all migrations on every plugins_loaded │ │ ├── class-prv-activator.php # Activation: table + defaults + cron │ │ ├── class-prv-deactivator.php # Deactivation: clear cron -│ │ ├── class-prv-table-manager.php # dbDelta create/drop + get_table_name() -│ │ ├── class-prv-config.php # Typed getters + seed_defaults() -│ │ ├── class-prv-cron.php # Weekly WP-Cron schedule/clear +│ │ ├── class-prv-table-manager.php # dbDelta create/drop + config_version column (v0.2) +│ │ ├── class-prv-config.php # Typed getters + seed_defaults() + get_projected_cost() +│ │ ├── class-prv-model-registry.php # [v0.2] prv_models CRUD + v1->v2 migration + health +│ │ ├── class-prv-config-version.php # [v0.2] Config-version stamps + bump on change +│ │ ├── class-prv-run-lock.php # [v0.2] Transient-based run-lock (cron + Run now) +│ │ ├── class-prv-cron.php # Probe cron: schedule/reschedule/clear; skip if locked │ │ ├── class-prv-cost-ledger.php # MTD cost + hard monthly cap enforcement │ │ ├── class-prv-probe-result.php # Immutable value object from a probe call -│ │ ├── class-prv-probe-runner.php # Orchestrates peptide×intent×model run +│ │ ├── class-prv-probe-runner.php # Orchestrates run: lock + peptide*intent*model loop │ │ ├── class-prv-collector-registry.php # Singleton registry for collectors + panels -│ │ └── class-prv-admin-page.php # Admin page: menu, Run now, rendering +│ │ ├── class-prv-admin-page.php # Dashboard page: menu, Run now, rendering +│ │ ├── class-prv-settings-page.php # [v0.2] Settings page: POST handlers + AJAX dispatch +│ │ ├── class-prv-settings-renderer.php # [v0.2] Settings HTML renderer (dark Assay theme) +│ │ ├── class-prv-model-manager-table.php # [v0.2] Model manager table + Add form renderer +│ │ └── class-prv-model-test-ajax.php # [v0.2] Rate-limited AJAX handler for Test button │ ├── providers/ │ │ ├── interface-prv-probe-provider.php # probe(query): PRV_Probe_Result contract │ │ ├── class-prv-gateway-client.php # Cloudflare AI Gateway HTTP + retry @@ -54,8 +63,13 @@ pr-vision/ │ ├── test-cost-ledger.php # MTD cost + budget cap │ ├── test-provider-parsing.php # Mock-HTTP provider response parsing │ ├── test-cron.php # Schedule/clear + activation/deactivation -│ ├── test-probe-runner.php # Run mechanics + budget abort -│ └── test-uninstall.php # Table drop + option purge +│ ├── test-probe-runner.php # Run mechanics + budget abort + lock + health +│ ├── test-uninstall.php # Table drop + option purge (incl. v0.2 keys) +│ ├── test-model-registry.php # [v0.2] Migration, CRUD, run-health +│ ├── test-run-lock.php # [v0.2] Acquire/release/sentinel +│ ├── test-config-version.php # [v0.2] Hash, bump, version stamp +│ ├── test-projected-cost.php # [v0.2] probe_count, over_cap, truncation flag +│ └── test-cron-reschedule.php # [v0.2] Reschedule, idempotent, tick guard └── .github/workflows/ci.yml # PHP lint matrix + PHPCS + 300-line check ``` @@ -64,42 +78,45 @@ pr-vision/ ## 3. Data flow ``` -WP-Cron (weekly) ──────────────────────────────────────┐ -Admin "Run now" (POST + nonce) ─────────────────────────┤ - ↓ - PRV_Probe_Runner::run() - │ - ┌───────────────────────────────────┤ - │ for each peptide × intent × model│ - │ │ - │ PRV_Cost_Ledger::can_afford() │ - │ └─ ABORT gracefully if at cap │ - │ │ - │ PRV_Probe_Provider::probe(query) │ - │ └─ PRV_Gateway_Client │ - │ └─ Cloudflare AI Gateway │ - │ └─ OpenRouter │ - │ └─ LLM │ - │ │ - │ PRV_Citation_Detector │ - │ └─ extract domains │ - │ └─ detect peptiderepo.com │ - │ │ - │ $wpdb→insert(prv_ai_visibility) │ - │ PRV_Cost_Ledger::update_row_cost()│ - └───────────────────────────────────┘ - │ -Admin page load ────────────────────────────────────────┤ - ↓ - PRV_Admin_Page::render_page() - │ - PRV_Collector_Registry - │ - PRV_Ai_Visibility_Collector::collect() - │ (DB reads) - PRV_Ai_Visibility_Panel::render() - │ (HTML output) - Browser (Chart.js) +WP-Cron (scheduled cadence) ─────────────────────────────────────┐ +Admin "Run now" (Settings POST + nonce) ──────────────────────────┤ + ↓ + PRV_Run_Lock::acquire() ← refuse if busy + ↓ + PRV_Probe_Runner::run() + │ + ┌───────────────────────────────────────┤ + │ for each peptide × intent × model │ + │ │ + │ PRV_Cost_Ledger::can_afford() │ + │ └─ ABORT gracefully if at cap │ + │ │ + │ PRV_Probe_Provider::probe(query) │ + │ └─ PRV_Gateway_Client │ + │ └─ Cloudflare AI Gateway │ + │ └─ OpenRouter / LLM │ + │ │ + │ PRV_Citation_Detector │ + │ │ + │ $wpdb->insert(prv_ai_visibility) │ + │ └─ config_version column stamped │ + │ PRV_Cost_Ledger::update_row_cost() │ + └───────────────────────────────────────┘ + │ + PRV_Model_Registry::update_health() + PRV_Run_Lock::release() + │ +Admin page load ──────────────────────────────────────────────────┤ + ↓ + PRV_Admin_Page::render_page() + │ + PRV_Collector_Registry + │ + PRV_Ai_Visibility_Collector::collect() + PRV_Ai_Visibility_Panel::render() + │ + Browser (Chart.js) + (config-change annotations via config_versions option) ``` --- @@ -122,58 +139,63 @@ Admin page load ───────────────────── | source_domains | LONGTEXT | JSON array of domains | | raw_excerpt | LONGTEXT | First 500 chars of LLM response | | cost_usd | DECIMAL(12,8) | Actual call cost | +| config_version | INT NULL | [v0.2] Config version at run time; NULL for legacy rows | -Schema version tracked in `prv_schema_version` option. +Schema version: `prv_schema_version` = 2. + +**Key options (v0.2.0)** + +| Option | Type | Notes | +|--------|------|-------| +| prv_models | array | v2 rich-object model list; migrated from flat-string v1 | +| prv_models_schema_version | int | 2 when migration complete | +| prv_config_versions | array | All config-version records | +| prv_active_config_version | int | Currently active version number | +| prv_cadence | string | 'weekly'\|'daily'\|'twicedaily' | +| prv_api_key_status | string | 'ok'\|'failed'\|'unknown' | +| prv_api_key_last_check | string | Datetime of last status update | +| prv_last_run_at | string | Datetime of last run | +| prv_last_run_counts | array | {probed, skipped_budget, skipped_error, truncated, run_id} | +| prv_last_run_truncated | int | 1 when last run hit the budget cap mid-way | +| prv_last_run_truncated_at | string | Datetime of last truncation event | +| _transient_prv_run_lock | int | Holds Unix timestamp while a run is in progress (TTL 3600s) | --- ## 5. External API integrations ### Cloudflare AI Gateway - All LLM calls route through: ``` https://gateway.ai.cloudflare.com/v1/{PRV_CF_ACCOUNT_ID}/{PRV_CF_GATEWAY_ID}/openrouter ``` +Falls back to direct OpenRouter when the constants are absent. -Falls back to direct OpenRouter (`https://openrouter.ai/api/v1`) when the constants are absent or empty. - -Pattern mirrors PRAutoBlogger's `class-open-router-config.php` and `class-open-router-request-builder.php` — same auth injection, same cURL belt-and-suspenders for Hostinger. - -### Providers (v1) +### Providers (v0.1 + v0.2 UI-managed) | Provider | Model | Citation source | |----------|-------|-----------------| -| PRV_Perplexity_Provider | `perplexity/sonar` | `citations[]` array (primary, real-web retrieval) | -| PRV_OpenRouter_Provider | `openai/gpt-4o-search-preview` | annotations or inline URL regex | -| PRV_OpenRouter_Provider | `google/gemini-2.0-flash-001` | annotations or inline URL regex | +| PRV_Perplexity_Provider | `perplexity/sonar` | `citations[]` array | +| PRV_OpenRouter_Provider | any OpenRouter slug | annotations or inline URL regex | + +Models are now managed via the Settings UI rather than hardcoded. The `prv_models` option is the source of truth; `PRV_Config::get_models()` delegates to `PRV_Model_Registry::get_enabled_slugs()`. --- ## 6. Collector / Panel seam -``` -PRV_Data_Collector (interface) PRV_Dashboard_Panel (interface) - │ │ -PRV_Ai_Visibility_Collector PRV_Ai_Visibility_Panel - │ │ - └──────── PRV_Collector_Registry ──────────┘ - (key: "ai_visibility") -``` - -To add a future SEO collector (keyword rankings, schema coverage…): -1. Implement `PRV_Data_Collector` + `PRV_Dashboard_Panel`. -2. Register both in `PRV_Plugin::init()`. -No dashboard shell changes required. +Same as v0.1.0 -- see CONTEXT.md for the extension guide. --- ## 7. Security -- Secrets read from `wp-config.php` constants at runtime — never hardcoded. -- All admin actions: `manage_options` capability check + nonce verification. +- Secrets read from `wp-config.php` constants at runtime -- never stored in DB. +- API key status shown in 3 states; key never displayed. +- All admin actions: `manage_options` check + nonce verification. - All DB values escaped with `$wpdb->prepare()`. -- All HTML output escaped with `esc_html()`, `esc_url()`, `wp_json_encode()`. +- All HTML output escaped at render. +- AJAX `prv_test_model`: capability + nonce + 30s rate-limit transient. --- @@ -181,8 +203,9 @@ No dashboard shell changes required. | Decision | Rationale | |----------|-----------| -| Use OpenRouter for all models | Single API key, single gateway URL, Perplexity sonar is accessible via OpenRouter's model routing. | -| Perplexity sonar as primary | Returns an explicit `citations[]` array — the most faithful citation signal available. | -| Cost ledger from visibility table | No separate ledger table needed; `SUM(cost_usd)` per calendar month is sufficient and keeps the schema minimal. | -| Chart.js via CDN | No build step, no bundler, admin-only load, pinned version. | -| Plain PHP tests (no PHPUnit) | Mirrors peptide-repo-core's pattern; CI runs in the lint-php job with no extra setup. | +| `prv_models_schema_version` for migration gate | Cheap option check; migration is idempotent and safe to call on every plugins_loaded without performance cost. | +| Config-version as hash + version-number | Hash detects changes; integer version is stable foreign key for run rows and chart annotations. Orphaned rows (NULL config_version or old version) are excluded from current-config scoring. | +| Run-lock as transient | WP transients work across all environments; TTL guards against stale locks from crashed runs; no separate table needed. | +| Projected cost from probe_count × fixed estimate | Conservative overestimate; avoids the need for per-model price config in v1; operator sees a safe upper bound. | +| Three-state API key (not two) | A defined-but-revoked key showed false-green in v1 (QA finding M4). The third state "defined, last run failed" closes that gap without ever displaying the key. | +| Sticky save-bar warning (not just card banner) | Card banner scrolls off; save-bar is always in viewport on a page with a tall model table (QA finding M2). | diff --git a/CHANGELOG.md b/CHANGELOG.md index 685d782..e6d4211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,3 +56,41 @@ Rename to PR Vision; fix www-strip in competitor display; PHPCS gating; remove d - `ARCHITECTURE.md`, `CONVENTIONS.md`, `CONTEXT.md` (incl. visibility score formula), `README.md`. - CI: PHP lint (8.1/8.2/8.3) + PHPCS + 300-line check (mirrors `peptide-repo-core`). - Seed config: 12 peptides, 3 prompt intents, Perplexity sonar + GPT-4o-search + Gemini-2.0-Flash. + +--- + +## [0.2.0] — 2026-06-14 + +Admin/Settings interface + correctness fixes. All adversarial-QA must-fixes addressed. + +### Added + +- **Model manager (`prv_models` v2):** CRUD for models via Settings page — add, enable/disable, remove, edit, without a code deploy. Per-model: provider, slug, enabled flag, operator note, run-health, Test button. +- **`PRV_Model_Registry`:** manages the `prv_models` option in v2 rich-object format; migration from v0.1.x flat-string format is versioned, idempotent, and tested. +- **`PRV_Upgrader`:** runs all pending migrations on every `plugins_loaded` (not just activation) so live-upgraded installs get migrated automatically. +- **`PRV_Config_Version`:** stamps every run with the scored config (models × peptides × intents); bumps on scoring-relevant saves; `get_all_versions()` for trendline annotations. +- **`PRV_Run_Lock`:** transient-based distributed lock preventing concurrent cron + "Run now" executions; `acquire()` / `release()` / `is_locked()`. +- **`PRV_Settings_Page`:** "PR Vision → Settings" sub-menu with POST handlers for settings save, model CRUD, Run now, Test model AJAX. All actions `manage_options` + nonce gated. +- **`PRV_Settings_Renderer`:** renders the dark "Assay" settings page (CSS variables, sticky save-bar with scoring-change warning, projected cost inset, API-key status, Run now). +- **`PRV_Model_Manager_Table`:** renders the model manager table with health badges, keyboard-sortable `'; + echo ''; + echo '' . esc_html__( 'Enabled', 'pr-vision' ) . ''; + echo ''; + echo '' . esc_html__( 'Test (point-in-time)', 'pr-vision' ) . ''; + echo '' . esc_html__( 'Actions', 'pr-vision' ) . ''; + echo ''; + + foreach ( $models as $m ) { + $this->render_model_row( $m ); + } + + echo ''; + echo '
'; + $this->render_add_model_form(); + echo '
'; + } + + /** + * Render a single model table row. + * + * @param array $m Model object from PRV_Model_Registry. + * + * @return void + */ + private function render_model_row( array $m ): void { + $id = esc_attr( (string) ( $m['id'] ?? '' ) ); + $slug = esc_html( (string) ( $m['slug'] ?? '' ) ); + $prov = esc_html( (string) ( $m['provider'] ?? '' ) ); + $enabled = ! empty( $m['enabled'] ); + $health = (string) ( $m['health_status'] ?? 'unknown' ); + $retired = 'retired' === $health; + $note = esc_html( (string) ( $m['note'] ?? '' ) ); + + $row_class = $retired ? 'prv-row-retired' : ''; + echo ''; + + // Provider. + echo '' . $prov . ''; + + // Slug (monospace). + echo '' . $slug . ''; + if ( $note ) { + echo '
' . $note . ''; + } + echo ''; + + // Enabled toggle. + echo ''; + $form_name = 'prv_enable_' . $id; + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + wp_nonce_field( PRV_Settings_Page::NONCE_MODEL, 'prv_nonce' ); + // Disabled toggle for retired models. + $toggle_attrs = $retired ? ' disabled aria-disabled="true"' : ''; + $checked_attr = $enabled ? ' checked' : ''; + echo ''; + echo '
'; + if ( $retired ) { + echo '' . esc_html__( 'Retired', 'pr-vision' ) . ''; + } + echo ''; + + // Run health. + echo '' . $this->render_health_badge( $m ) . ''; + + // Test button + chip. + $chip_id = 'prv-test-chip-' . $id; + echo ''; + echo ''; + echo ' '; + echo ''; + + // Actions: remove. + echo ''; + echo '
'; + echo ''; + echo ''; + wp_nonce_field( PRV_Settings_Page::NONCE_MODEL, 'prv_nonce' ); + echo ''; + echo '
'; + echo ''; + + echo ''; + } + + /** + * Render a health badge for a model. + * + * @param array $m Model object. + * + * @return string HTML badge (pre-escaped). + */ + private function render_health_badge( array $m ): string { + $status = (string) ( $m['health_status'] ?? 'unknown' ); + $probed = (int) ( $m['health_probed'] ?? 0 ); + $errors = (int) ( $m['health_errors'] ?? 0 ); + + switch ( $status ) { + case 'healthy': + return '● Healthy (' . esc_html( (string) $probed ) . ' rows)'; + case 'retired': + return '● Retired? (0 rows · ' . esc_html( (string) $errors ) . ' errors)'; + case 'disabled': + return '● Off · retired'; + default: + return '● Unknown'; + } + } + + /** + * Render the Add model form below the table. + * + * @return void + */ + private function render_add_model_form(): void { + echo '
'; + echo '+ ' . esc_html__( 'Add model', 'pr-vision' ) . ''; + echo '
'; + echo ''; + wp_nonce_field( PRV_Settings_Page::NONCE_MODEL, 'prv_nonce' ); + echo '
'; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo '
'; + } +} diff --git a/includes/core/class-prv-model-registry.php b/includes/core/class-prv-model-registry.php new file mode 100644 index 0000000..91c0ac8 --- /dev/null +++ b/includes/core/class-prv-model-registry.php @@ -0,0 +1,299 @@ += self::SCHEMA_VERSION ) { + return false; + } + + $existing = get_option( self::OPTION_KEY, array() ); + $migrated = array(); + + if ( is_array( $existing ) && ! empty( $existing ) ) { + foreach ( $existing as $item ) { + if ( is_string( $item ) ) { + // v1 flat string -- upgrade to rich object. + $migrated[] = self::make_model_object( $item, '', true ); + } elseif ( is_array( $item ) && isset( $item['slug'] ) ) { + // Already a v2 object -- pass through. + $migrated[] = self::ensure_model_fields( $item ); + } + } + } + + if ( empty( $migrated ) ) { + $migrated = self::default_model_objects(); + } + + update_option( self::OPTION_KEY, $migrated ); + update_option( self::VERSION_KEY, self::SCHEMA_VERSION ); + return true; + } + + /** + * Get all models as rich-object arrays. + * + * @return array> Model objects. + */ + public static function get_all(): array { + $raw = get_option( self::OPTION_KEY, array() ); + if ( ! is_array( $raw ) || empty( $raw ) ) { + return self::default_model_objects(); + } + return array_values( array_filter( $raw, 'is_array' ) ); + } + + /** + * Get only enabled model slugs (for backward compat with PRV_Config). + * + * @return array + */ + public static function get_enabled_slugs(): array { + $models = self::get_all(); + $slugs = array(); + foreach ( $models as $m ) { + if ( ! empty( $m['enabled'] ) ) { + $slugs[] = (string) $m['slug']; + } + } + return $slugs; + } + + /** + * Find a single model by id. + * + * @param string $id Model id. + * + * @return array|null Null if not found. + */ + public static function find_by_id( string $id ): ?array { + foreach ( self::get_all() as $m ) { + if ( isset( $m['id'] ) && $m['id'] === $id ) { + return $m; + } + } + return null; + } + + /** + * Add a new model. + * + * @param string $slug OpenRouter model slug. + * @param string $provider Provider name (e.g. "openrouter", "perplexity"). + * @param bool $enabled Whether to enable on creation. + * @param string $note Optional operator note. + * + * @return string New model id. + */ + public static function add( string $slug, string $provider, bool $enabled = true, string $note = '' ): string { + $models = self::get_all(); + $obj = self::make_model_object( $slug, $provider, $enabled, $note ); + $models[] = $obj; + update_option( self::OPTION_KEY, $models ); + return (string) $obj['id']; + } + + /** + * Update an existing model by id. + * + * @param string $id Model id. + * @param array $data Fields to update (slug, provider, enabled, note). + * + * @return bool True if found and updated. + */ + public static function update( string $id, array $data ): bool { + $models = self::get_all(); + $updated = false; + foreach ( $models as &$m ) { + if ( isset( $m['id'] ) && $m['id'] === $id ) { + foreach ( array( 'slug', 'provider', 'enabled', 'note' ) as $field ) { + if ( array_key_exists( $field, $data ) ) { + $m[ $field ] = $data[ $field ]; + } + } + $updated = true; + break; + } + } + unset( $m ); + if ( $updated ) { + update_option( self::OPTION_KEY, $models ); + } + return $updated; + } + + /** + * Remove a model by id. + * + * @param string $id Model id. + * + * @return bool True if found and removed. + */ + public static function remove( string $id ): bool { + $models = self::get_all(); + $filtered = array_values( array_filter( $models, static fn( $m ) => ( $m['id'] ?? '' ) !== $id ) ); + if ( count( $filtered ) === count( $models ) ) { + return false; + } + update_option( self::OPTION_KEY, $filtered ); + return true; + } + + /** + * Update per-model run-health after a completed run. + * + * Called by PRV_Probe_Runner at the end of a run with per-slug outcome + * counts. Sets health_status to 'healthy' or 'retired'. + * + * Side effects: Updates prv_models option. + * + * @param string $run_id UUID of completed run. + * @param array $outcomes Per-slug outcome. + * + * @return void + */ + public static function update_health( string $run_id, array $outcomes ): void { + $models = self::get_all(); + foreach ( $models as &$m ) { + $slug = (string) ( $m['slug'] ?? '' ); + if ( empty( $m['enabled'] ) ) { + $m['health_status'] = 'disabled'; + continue; + } + if ( ! isset( $outcomes[ $slug ] ) ) { + continue; + } + $o = $outcomes[ $slug ]; + $m['health_probed'] = (int) $o['probed']; + $m['health_errors'] = (int) $o['errors']; + $m['health_run_id'] = $run_id; + $m['health_status'] = ( 0 === $o['probed'] && $o['errors'] > 0 ) ? 'retired' : 'healthy'; + } + unset( $m ); + update_option( self::OPTION_KEY, $models ); + } + + /** + * Build a new model object with all required fields. + * + * @param string $slug Model slug. + * @param string $provider Provider name. + * @param bool $enabled Enabled flag. + * @param string $note Operator note. + * + * @return array + */ + private static function make_model_object( + string $slug, + string $provider = '', + bool $enabled = true, + string $note = '' + ): array { + if ( '' === $provider ) { + $provider = ( 'perplexity/sonar' === $slug ) ? 'perplexity' : 'openrouter'; + } + return array( + 'id' => self::generate_id(), + 'slug' => $slug, + 'provider' => $provider, + 'enabled' => $enabled, + 'note' => $note, + 'health_status' => 'unknown', + 'health_probed' => 0, + 'health_errors' => 0, + 'health_run_id' => null, + ); + } + + /** + * Ensure all v2 fields exist on an already-rich object (forward compat). + * + * @param array $m Existing model array. + * + * @return array + */ + private static function ensure_model_fields( array $m ): array { + $defaults = array( + 'id' => self::generate_id(), + 'provider' => 'openrouter', + 'enabled' => true, + 'note' => '', + 'health_status' => 'unknown', + 'health_probed' => 0, + 'health_errors' => 0, + 'health_run_id' => null, + ); + return array_merge( $defaults, $m ); + } + + /** + * Default model objects matching v0.1.x defaults. + * + * @return array> + */ + private static function default_model_objects(): array { + return array( + self::make_model_object( 'perplexity/sonar', 'perplexity', true, 'Primary citation signal -- real-web retrieval' ), + self::make_model_object( 'openai/gpt-4o-search-preview', 'openrouter', true, 'GPT search breadth' ), + self::make_model_object( 'google/gemini-2.0-flash-001', 'openrouter', true, 'Gemini breadth -- verify slug active before use' ), + ); + } + + /** + * Generate a short unique id for a model object. + * + * @return string + */ + private static function generate_id(): string { + return sprintf( 'mdl_%s', substr( md5( uniqid( '', true ) ), 0, 12 ) ); + } +} diff --git a/includes/core/class-prv-model-test-ajax.php b/includes/core/class-prv-model-test-ajax.php new file mode 100644 index 0000000..b488824 --- /dev/null +++ b/includes/core/class-prv-model-test-ajax.php @@ -0,0 +1,91 @@ + 'Insufficient permissions.' ) ); + } + + $nonce = isset( $_POST['prv_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['prv_nonce'] ) ) : ''; + if ( ! wp_verify_nonce( $nonce, PRV_Settings_Page::NONCE_TEST ) ) { + wp_send_json_error( array( 'message' => 'Security check failed.' ) ); + } + + $model_id = isset( $_POST['model_id'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['model_id'] ) ) : ''; + $model = PRV_Model_Registry::find_by_id( $model_id ); + + if ( null === $model ) { + wp_send_json_error( array( 'message' => 'Model not found.' ) ); + } + + $slug = (string) $model['slug']; + $lock_key = 'prv_test_lock_' . md5( $slug ); + if ( false !== get_transient( $lock_key ) ) { + wp_send_json_error( array( 'message' => 'Rate limited -- wait 30 s between tests.' ) ); + } + set_transient( $lock_key, 1, self::RATE_LIMIT_SECONDS ); + + $start = microtime( true ); + $provider = ( 'perplexity/sonar' === $slug ) + ? new PRV_Perplexity_Provider() + : new PRV_OpenRouter_Provider( $slug ); + + if ( ! $provider->is_configured() ) { + wp_send_json_error( array( 'message' => 'API key not configured (check wp-config.php).' ) ); + } + + try { + $provider->probe( 'what is BPC-157' ); + $ms = (int) round( ( microtime( true ) - $start ) * 1000 ); + wp_send_json_success( array( + 'resolved' => true, + 'latency_ms' => $ms, + 'slug' => $slug, + 'message' => sprintf( 'Resolved · %d ms', $ms ), + ) ); + } catch ( \Exception $e ) { + wp_send_json_error( array( + 'resolved' => false, + 'slug' => $slug, + 'message' => 'Not resolved -- provider returned error (slug retired?)', + ) ); + } + } +} diff --git a/includes/core/class-prv-plugin.php b/includes/core/class-prv-plugin.php index f6f9e41..7b3d248 100644 --- a/includes/core/class-prv-plugin.php +++ b/includes/core/class-prv-plugin.php @@ -10,14 +10,16 @@ declare(strict_types=1); /** * Main orchestrator for the PR Vision plugin. * - * Boots sub-systems in the correct order, hooks into WP, and keeps - * the top-level file under the 300-line limit by delegating all logic - * to specialist classes. + * Boots sub-systems in the correct order, runs pending data migrations, + * hooks into WP, and keeps the top-level file under the 300-line limit by + * delegating all logic to specialist classes. * * Who triggers: plugins_loaded action (pr-vision.php). - * Dependencies: PRV_Cron, PRV_Admin_Page, PRV_Collector_Registry. + * Dependencies: PRV_Upgrader, PRV_Cron, PRV_Admin_Page, PRV_Settings_Page, + * PRV_Collector_Registry. * - * @see ARCHITECTURE.md — Boot sequence diagram. + * @see ARCHITECTURE.md -- Boot sequence diagram. + * @see class-prv-upgrader.php -- Runs migrations on every boot. * @package PrVision */ class PRV_Plugin { @@ -25,18 +27,28 @@ class PRV_Plugin { /** * Wire up all WordPress hooks. * - * Side effects: Registers admin menus, enqueues assets, and registers the - * collector/panel registry. + * Migrations run before anything reads from prv_models so the v2 format + * is always in place regardless of whether the plugin was freshly activated + * or just upgraded from v0.1.x while live. + * + * Side effects: Registers admin menus, enqueues assets, registers the + * collector/panel registry, runs data migrations. * * @return void */ public function init(): void { + // Migrations first -- must run before any config read. + PRV_Upgrader::run(); + $cron = new PRV_Cron(); $cron->register_hooks(); if ( is_admin() ) { $page = new PRV_Admin_Page(); $page->register_hooks(); + + $settings = new PRV_Settings_Page(); + $settings->register_hooks(); } // Register the v1 AI-visibility collector + panel. diff --git a/includes/core/class-prv-probe-runner.php b/includes/core/class-prv-probe-runner.php index 03f914a..3e4cfcd 100644 --- a/includes/core/class-prv-probe-runner.php +++ b/includes/core/class-prv-probe-runner.php @@ -8,22 +8,25 @@ declare(strict_types=1); /** - * Orchestrates a full probe run: peptides × intents × models. + * Orchestrates a full probe run: peptides x intents x models. * * For each combination the runner: - * 1. Checks the monthly budget cap (aborts gracefully when hit). - * 2. Dispatches to the correct provider (resolved from PRV_Config::get_models()). - * 3. Writes a row to the prv_ai_visibility table. - * 4. Settles the actual cost against the ledger row. + * 1. Acquires the run-lock (fails fast if already locked). + * 2. Checks the monthly budget cap (aborts gracefully when hit). + * 3. Dispatches to the correct provider (resolved from PRV_Model_Registry). + * 4. Writes a row to the prv_ai_visibility table with config_version stamp. + * 5. Settles the actual cost against the ledger row. + * 6. After the run, updates per-model health and releases the lock. * - * Who triggers: PRV_Cron::handle_cron_tick() and PRV_Admin_Page (Run now action). - * Dependencies: PRV_Config, PRV_Cost_Ledger, PRV_Table_Manager, all providers. + * Who triggers: PRV_Cron::handle_cron_tick() and PRV_Settings_Page (Run now). + * Dependencies: PRV_Config, PRV_Cost_Ledger, PRV_Table_Manager, + * PRV_Run_Lock, PRV_Model_Registry, PRV_Config_Version, all providers. * - * @see class-prv-cron.php — Calls run() on the weekly schedule. - * @see class-prv-cost-ledger.php — Budget cap enforcement. - * @see class-prv-perplexity-provider.php - * @see class-prv-openrouter-provider.php - * @see ARCHITECTURE.md — §Probe run flow. + * @see class-prv-cron.php -- Calls run() on the weekly schedule. + * @see class-prv-cost-ledger.php -- Budget cap enforcement. + * @see class-prv-run-lock.php -- Concurrency guard. + * @see class-prv-model-registry.php -- Per-model health updates. + * @see ARCHITECTURE.md -- Section Probe run flow. * @package PrVision */ class PRV_Probe_Runner { @@ -47,27 +50,66 @@ class PRV_Probe_Runner { /** * Execute the full probe run. * - * Iterates peptides × prompt intents × models. Aborts at the budget cap - * without throwing — partial results are kept. Each successful probe is + * Acquires a run-lock so the cron and "Run now" cannot collide. + * Iterates peptides x prompt intents x models. Aborts at the budget cap + * without throwing -- partial results are kept. Each successful probe is * persisted immediately so a mid-run failure doesn't lose data. * - * Side effects: HTTP calls to LLM APIs, database writes. + * Side effects: HTTP calls to LLM APIs, database writes, option writes (health). * - * @return array{probed: int, skipped_budget: int, skipped_error: int} - * Summary counts for the admin log. + * @return array{probed: int, skipped_budget: int, skipped_error: int, truncated: bool, run_id: string} + * Summary counts plus truncation flag for the admin display. */ public function run(): array { - $run_id = $this->generate_run_id(); - $peptides = PRV_Config::get_peptides(); - $intents = PRV_Config::get_prompt_intents(); - $models = PRV_Config::get_models(); + $counts = array( + 'probed' => 0, + 'skipped_budget' => 0, + 'skipped_error' => 0, + 'truncated' => false, + 'run_id' => '', + ); + + if ( ! PRV_Run_Lock::acquire() ) { + $counts['skipped_error'] = -1; // Sentinel: lock busy. + return $counts; + } + + try { + $counts = $this->execute_run(); + } finally { + PRV_Run_Lock::release(); + } + + return $counts; + } + + /** + * Core run logic (called inside the lock). + * + * @return array{probed: int, skipped_budget: int, skipped_error: int, truncated: bool, run_id: string} + */ + private function execute_run(): array { + $run_id = $this->generate_run_id(); + $config_ver = PRV_Config_Version::get_active_version(); + $peptides = PRV_Config::get_peptides(); + $intents = PRV_Config::get_prompt_intents(); + $models = PRV_Config::get_models(); + $model_outcomes = array(); // Per-slug: {probed, errors}. + + foreach ( $models as $slug ) { + $model_outcomes[ $slug ] = array( 'probed' => 0, 'errors' => 0 ); + } $counts = array( 'probed' => 0, 'skipped_budget' => 0, 'skipped_error' => 0, + 'truncated' => false, + 'run_id' => $run_id, ); + $budget_hit = false; + foreach ( $peptides as $peptide ) { foreach ( $intents as $intent_tpl ) { $query = str_replace( '{peptide}', $peptide['label'], $intent_tpl ); @@ -77,6 +119,9 @@ class PRV_Probe_Runner { if ( null === $provider || ! $provider->is_configured() ) { ++$counts['skipped_error']; + if ( isset( $model_outcomes[ $model ] ) ) { + ++$model_outcomes[ $model ]['errors']; + } continue; } @@ -84,6 +129,7 @@ class PRV_Probe_Runner { $estimated = $this->get_estimated_cost( $model ); if ( ! $this->ledger->can_afford( $estimated ) ) { ++$counts['skipped_budget']; + $budget_hit = true; continue; } @@ -93,34 +139,70 @@ class PRV_Probe_Runner { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( sprintf( 'PRV: probe failed [%s / %s]: %s', $model, $peptide['slug'], $e->getMessage() ) ); ++$counts['skipped_error']; + if ( isset( $model_outcomes[ $model ] ) ) { + ++$model_outcomes[ $model ]['errors']; + } continue; } - $row_id = $this->persist_result( $run_id, $peptide, $model, $intent_tpl, $result ); + $row_id = $this->persist_result( $run_id, $peptide, $model, $intent_tpl, $result, $config_ver ); if ( $row_id > 0 ) { $this->ledger->update_row_cost( $row_id, $result->get_cost_usd() ); } ++$counts['probed']; + if ( isset( $model_outcomes[ $model ] ) ) { + ++$model_outcomes[ $model ]['probed']; + } } } } + if ( $budget_hit && $counts['probed'] > 0 ) { + $counts['truncated'] = true; + } + + // Record truncation state for dashboard display. + if ( $counts['truncated'] ) { + update_option( 'prv_last_run_truncated', 1 ); + update_option( 'prv_last_run_truncated_at', current_time( 'mysql', true ) ); + } else { + update_option( 'prv_last_run_truncated', 0 ); + } + + // Persist run summary for dashboard. + update_option( 'prv_last_run_at', current_time( 'mysql', true ) ); + update_option( 'prv_last_run_counts', $counts ); + + // Update per-model run-health. + PRV_Model_Registry::update_health( $run_id, $model_outcomes ); + + // Update API-key status (derive from whether probes succeeded). + $this->update_api_key_status( $counts, $model_outcomes ); + return $counts; } /** - * Persist a single probe result to the database. + * Persist a single probe result to the database with config_version stamp. * * @param string $run_id UUID for this run. * @param array $peptide Peptide config with slug and label. * @param string $model Model identifier. * @param string $intent_tpl Raw intent template string. * @param PRV_Probe_Result $result Probe result. + * @param int $config_ver Config version at run time. * * @return int Inserted row ID, or 0 on failure. */ - private function persist_result( string $run_id, array $peptide, string $model, string $intent_tpl, PRV_Probe_Result $result ): int { + private function persist_result( + string $run_id, + array $peptide, + string $model, + string $intent_tpl, + PRV_Probe_Result $result, + int $config_ver + ): int { global $wpdb; $table = PRV_Table_Manager::get_table_name(); @@ -140,13 +222,31 @@ class PRV_Probe_Runner { 'source_domains' => wp_json_encode( $result->get_source_domains() ), 'raw_excerpt' => $result->get_raw_excerpt(), 'cost_usd' => number_format( $result->get_cost_usd(), 8, '.', '' ), + 'config_version' => $config_ver, ), - array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' ) + array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d' ) ); return (int) $wpdb->insert_id; } + /** + * Derive + persist the API-key status from run outcomes. + * + * @param array $counts Run counts. + * @param array $model_outcomes Model outcomes. + * + * @return void + */ + private function update_api_key_status( array $counts, array $model_outcomes ): void { + if ( $counts['probed'] > 0 ) { + update_option( 'prv_api_key_status', 'ok' ); + } elseif ( $counts['skipped_error'] > 0 && 0 === $counts['probed'] ) { + update_option( 'prv_api_key_status', 'failed' ); + } + update_option( 'prv_api_key_last_check', current_time( 'mysql', true ) ); + } + /** * Instantiate the correct provider class for a model string. * diff --git a/includes/core/class-prv-run-lock.php b/includes/core/class-prv-run-lock.php new file mode 100644 index 0000000..a33463c --- /dev/null +++ b/includes/core/class-prv-run-lock.php @@ -0,0 +1,94 @@ + Settings" sub-menu, handles POST actions (save config, + * model CRUD, Run now) and the test-model AJAX hook. Rendering is delegated + * to PRV_Settings_Renderer; test AJAX to PRV_Model_Test_Ajax. + * + * All actions require: current_user_can('manage_options') + valid nonce. + * + * Who triggers: PRV_Plugin::init() -- is_admin() guard. + * Dependencies: PRV_Model_Registry, PRV_Config, PRV_Cron, PRV_Run_Lock, + * PRV_Config_Version, PRV_Settings_Renderer, PRV_Model_Test_Ajax. + * + * @see class-prv-settings-renderer.php -- Renders the HTML. + * @see class-prv-model-test-ajax.php -- AJAX test handler. + * @see class-prv-model-registry.php -- CRUD for prv_models. + * @see class-prv-config-version.php -- Config-change versioning. + * @see class-prv-cron.php -- Reschedule on cadence change. + * @package PrVision + */ +class PRV_Settings_Page { + + /** Admin menu slug for the settings page. */ + const MENU_SLUG = 'pr-vision-settings'; + + /** Nonce action for settings save. */ + const NONCE_SAVE = 'prv_settings_save'; + + /** Nonce action for model CRUD. */ + const NONCE_MODEL = 'prv_model_action'; + + /** Nonce action for Run now on settings page. */ + const NONCE_RUN = 'prv_settings_run_now'; + + /** Nonce action for Test model AJAX. */ + const NONCE_TEST = 'prv_model_test'; + + /** + * Register WordPress admin hooks. + * + * Side effects: Adds admin_menu, admin_post, and wp_ajax actions. + * + * @return void + */ + public function register_hooks(): void { + add_action( 'admin_menu', array( $this, 'add_settings_menu' ) ); + add_action( 'admin_post_prv_settings_save', array( $this, 'handle_save' ) ); + add_action( 'admin_post_prv_model_add', array( $this, 'handle_model_add' ) ); + add_action( 'admin_post_prv_model_update', array( $this, 'handle_model_update' ) ); + add_action( 'admin_post_prv_model_remove', array( $this, 'handle_model_remove' ) ); + add_action( 'admin_post_prv_settings_run_now', array( $this, 'handle_run_now' ) ); + add_action( 'wp_ajax_prv_test_model', array( $this, 'handle_test_model' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Register the Settings sub-menu under PR Vision. + * + * Side effects: Adds a WP admin sub-menu entry. + * + * @return void + */ + public function add_settings_menu(): void { + add_submenu_page( + 'pr-vision', + __( 'PR Vision Settings', 'pr-vision' ), + __( 'Settings', 'pr-vision' ), + 'manage_options', + self::MENU_SLUG, + array( $this, 'render_page' ) + ); + } + + /** + * Enqueue settings page Chart.js. + * + * @param string $hook Current admin page hook. + * + * @return void + */ + public function enqueue_assets( string $hook ): void { + if ( false === strpos( $hook, self::MENU_SLUG ) ) { + return; + } + wp_enqueue_script( + 'prv-chartjs', + 'https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js', + array(), + '4.4.3', + true + ); + } + + /** + * Handle the settings save POST action. + * + * Validates, sanitizes, saves peptides/intents/cadence/budget. Bumps + * config-version if scoring-relevant fields changed. Reschedules cron + * if cadence changed. + * + * Side effects: Updates wp_options, may reschedule cron, redirects. + * + * @return void + */ + public function handle_save(): void { + $this->require_admin_nonce( self::NONCE_SAVE ); + $old_cadence = PRV_Config::get_cadence(); + + $budget = isset( $_POST['prv_monthly_budget_usd'] ) + ? max( 0.01, (float) sanitize_text_field( wp_unslash( (string) $_POST['prv_monthly_budget_usd'] ) ) ) + : PRV_DEFAULT_MONTHLY_BUDGET_USD; + update_option( 'prv_monthly_budget_usd', $budget ); + + $cadence_raw = isset( $_POST['prv_cadence'] ) + ? sanitize_text_field( wp_unslash( (string) $_POST['prv_cadence'] ) ) + : 'weekly'; + $cadence = in_array( $cadence_raw, array( 'weekly', 'daily', 'twicedaily' ), true ) ? $cadence_raw : 'weekly'; + update_option( PRV_Config::CADENCE_KEY, $cadence ); + + if ( ! empty( $_POST['prv_peptides_json'] ) ) { + $raw_json = sanitize_textarea_field( wp_unslash( (string) $_POST['prv_peptides_json'] ) ); + $decoded = json_decode( $raw_json, true ); + if ( is_array( $decoded ) ) { + $clean = array(); + foreach ( $decoded as $p ) { + if ( isset( $p['slug'], $p['label'] ) ) { + $clean[] = array( + 'slug' => sanitize_text_field( (string) $p['slug'] ), + 'label' => sanitize_text_field( (string) $p['label'] ), + ); + } + } + update_option( 'prv_peptides', $clean ); + } + } + + if ( isset( $_POST['prv_prompt_intents_text'] ) ) { + $raw_intents = sanitize_textarea_field( wp_unslash( (string) $_POST['prv_prompt_intents_text'] ) ); + $intent_lines = array_filter( array_map( 'trim', explode( "\n", $raw_intents ) ) ); + update_option( 'prv_prompt_intents', array_values( $intent_lines ) ); + } + + PRV_Config_Version::bump_version_if_changed(); + + if ( $cadence !== $old_cadence ) { + PRV_Cron::reschedule( $cadence ); + } + + wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'prv_saved' => 1 ), admin_url( 'admin.php' ) ) ); + exit; + } + + /** + * Handle Add model POST. + * + * Side effects: Adds to prv_models; may bump config-version; redirects. + * + * @return void + */ + public function handle_model_add(): void { + $this->require_admin_nonce( self::NONCE_MODEL ); + $slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) ); + $provider = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_provider'] ?? 'openrouter' ) ) ); + $note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) ); + $enabled = ! empty( $_POST['prv_model_enabled'] ); + if ( '' !== $slug ) { + PRV_Model_Registry::add( $slug, $provider, $enabled, $note ); + PRV_Config_Version::bump_version_if_changed(); + } + wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'prv_model_added' => 1 ), admin_url( 'admin.php' ) ) ); + exit; + } + + /** + * Handle Update model POST. + * + * Side effects: Updates prv_models; may bump config-version; redirects. + * + * @return void + */ + public function handle_model_update(): void { + $this->require_admin_nonce( self::NONCE_MODEL ); + $id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) ); + $slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) ); + $enabled = ! empty( $_POST['prv_model_enabled'] ); + $note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) ); + if ( '' !== $id && '' !== $slug ) { + PRV_Model_Registry::update( $id, array( 'slug' => $slug, 'enabled' => $enabled, 'note' => $note ) ); + PRV_Config_Version::bump_version_if_changed(); + } + wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'prv_model_updated' => 1 ), admin_url( 'admin.php' ) ) ); + exit; + } + + /** + * Handle Remove model POST. + * + * Side effects: Removes from prv_models; may bump config-version; redirects. + * + * @return void + */ + public function handle_model_remove(): void { + $this->require_admin_nonce( self::NONCE_MODEL ); + $id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) ); + if ( '' !== $id ) { + PRV_Model_Registry::remove( $id ); + PRV_Config_Version::bump_version_if_changed(); + } + wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'prv_model_removed' => 1 ), admin_url( 'admin.php' ) ) ); + exit; + } + + /** + * Handle Run now POST from the settings page. + * + * Refuses if a run is already in progress. + * + * Side effects: May trigger probe run; redirects. + * + * @return void + */ + public function handle_run_now(): void { + $this->require_admin_nonce( self::NONCE_RUN ); + if ( PRV_Run_Lock::is_locked() ) { + wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'prv_run_locked' => 1 ), admin_url( 'admin.php' ) ) ); + exit; + } + $runner = new PRV_Probe_Runner(); + $counts = $runner->run(); + $args = array( + 'page' => self::MENU_SLUG, + 'prv_run_done' => 1, + 'prv_probed' => (int) $counts['probed'], + 'prv_skipped' => (int) ( $counts['skipped_budget'] + max( 0, $counts['skipped_error'] ) ), + 'prv_truncated' => $counts['truncated'] ? 1 : 0, + ); + wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) ); + exit; + } + + /** + * Dispatch the AJAX test-model request to PRV_Model_Test_Ajax. + * + * Side effects: Exits via PRV_Model_Test_Ajax::handle(). + * + * @return void + */ + public function handle_test_model(): void { + ( new PRV_Model_Test_Ajax() )->handle(); + } + + /** + * Render the settings page. + * + * Side effects: Outputs HTML via PRV_Settings_Renderer. + * + * @return void + */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ) ); + } + ( new PRV_Settings_Renderer() )->render(); + } + + /** + * Verify admin capability and nonce; wp_die on failure. + * + * @param string $action Nonce action. + * + * @return void + */ + private function require_admin_nonce( string $action ): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ), 403 ); + } + $nonce = isset( $_POST['prv_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['prv_nonce'] ) ) : ''; + if ( ! wp_verify_nonce( $nonce, $action ) ) { + wp_die( esc_html__( 'Security check failed.', 'pr-vision' ), 403 ); + } + } +} diff --git a/includes/core/class-prv-settings-renderer.php b/includes/core/class-prv-settings-renderer.php new file mode 100644 index 0000000..34e7b5f --- /dev/null +++ b/includes/core/class-prv-settings-renderer.php @@ -0,0 +1,293 @@ +render_styles(); + echo '
'; + echo '

' . esc_html__( 'PR Vision — Settings', 'pr-vision' ) . '

'; + $this->render_notices(); + echo '

' . esc_html__( 'Model Manager', 'pr-vision' ) . '

'; + ( new PRV_Model_Manager_Table() )->render(); + echo '
'; + $this->render_config_form(); + $this->render_save_bar(); + $this->render_scripts(); + echo '
'; + } + + /** + * Render dark CSS variables and component styles. + * + * @return void + */ + private function render_styles(): void { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo ''; + } + + /** + * Render POST-redirect notices (saved, run done, model CRUD, lock). + * + * @return void + */ + private function render_notices(): void { + // phpcs:disable WordPress.Security.NonceVerification.Recommended + if ( ! empty( $_GET['prv_saved'] ) ) { + echo '
' . esc_html__( 'Settings saved.', 'pr-vision' ) . '
'; + } + if ( ! empty( $_GET['prv_run_done'] ) ) { + $p = absint( $_GET['prv_probed'] ?? 0 ); + $s = absint( $_GET['prv_skipped'] ?? 0 ); + $trn = ! empty( $_GET['prv_truncated'] ); + $msg = sprintf( 'Run complete: %d probed, %d skipped.%s', $p, $s, $trn ? ' Last run was truncated by the monthly budget cap.' : '' ); + echo '
' . esc_html( $msg ) . '
'; + } + if ( ! empty( $_GET['prv_run_locked'] ) ) { + echo '
' . esc_html__( 'A run is already in progress — try again when it finishes.', 'pr-vision' ) . '
'; + } + if ( ! empty( $_GET['prv_model_added'] ) ) { + echo '
' . esc_html__( 'Model added.', 'pr-vision' ) . '
'; + } + if ( ! empty( $_GET['prv_model_updated'] ) ) { + echo '
' . esc_html__( 'Model updated.', 'pr-vision' ) . '
'; + } + if ( ! empty( $_GET['prv_model_removed'] ) ) { + echo '
' . esc_html__( 'Model removed.', 'pr-vision' ) . '
'; + } + // phpcs:enable WordPress.Security.NonceVerification.Recommended + } + + /** + * Render the secondary config form. + * + * @return void + */ + private function render_config_form(): void { + echo '
'; + echo ''; + wp_nonce_field( PRV_Settings_Page::NONCE_SAVE, 'prv_nonce' ); + echo '

' . esc_html__( 'Probe Configuration', 'pr-vision' ) . '

'; + $this->render_cadence_field(); + $this->render_budget_field(); + $this->render_projected_cost(); + $this->render_peptides_field(); + $this->render_intents_field(); + $this->render_api_key_status(); + echo '
'; + } + + /** + * Render cadence selector with next-run timestamp. + * + * @return void + */ + private function render_cadence_field(): void { + $cadence = PRV_Config::get_cadence(); + $next_ts = PRV_Cron::next_run_timestamp(); + $next_str = $next_ts ? gmdate( 'Y-m-d H:i T', (int) $next_ts ) : 'Not scheduled'; + echo '
'; + echo ''; + echo '

' . esc_html__( 'Next run: ', 'pr-vision' ) . esc_html( $next_str ) . '

'; + } + + /** + * Render monthly budget cap input. + * + * @return void + */ + private function render_budget_field(): void { + $budget = PRV_Config::get_monthly_budget_usd(); + echo '
'; + echo '
'; + } + + /** + * Render live projected-cost inset panel. + * + * @return void + */ + private function render_projected_cost(): void { + $cost = PRV_Config::get_projected_cost(); + $cls = $cost['over_cap'] ? 'prv-cost-inset over-cap' : 'prv-cost-inset'; + echo '
'; + echo 'Projected cost: ~$' . esc_html( number_format( $cost['per_run_usd'], 4 ) ) . '/run  ·  '; + echo '~$' . esc_html( number_format( $cost['per_month_usd'], 4 ) ) . '/month  ·  '; + echo esc_html( (string) $cost['probe_count'] ) . ' probes/run'; + if ( $cost['over_cap'] ) { + echo '  ⚠ Over cap — runs will truncate'; + } + echo '
'; + } + + /** + * Render peptide set textarea (JSON). + * + * @return void + */ + private function render_peptides_field(): void { + $peptides = PRV_Config::get_peptides(); + echo '
'; + echo '

' . esc_html( count( $peptides ) ) . ' peptides (JSON array of {slug, label}).

'; + echo '
'; + } + + /** + * Render prompt intents textarea. + * + * @return void + */ + private function render_intents_field(): void { + $intents = PRV_Config::get_prompt_intents(); + echo '
'; + echo '
'; + } + + /** + * Render the read-only API-key three-state status. + * + * @return void + */ + private function render_api_key_status(): void { + $defined = defined( 'PRV_OPENROUTER_API_KEY' ) && '' !== constant( 'PRV_OPENROUTER_API_KEY' ); + $status = (string) get_option( 'prv_api_key_status', 'unknown' ); + echo '
'; + if ( ! $defined ) { + echo '● ' . esc_html__( 'Not defined — set PRV_OPENROUTER_API_KEY in wp-config.php', 'pr-vision' ) . ''; + } elseif ( 'failed' === $status ) { + echo '● ' . esc_html__( 'Defined — last run failed (key may be invalid or revoked)', 'pr-vision' ) . ''; + } else { + echo '● ' . esc_html__( 'Defined — last run OK', 'pr-vision' ) . ''; + } + echo '
'; + } + + /** + * Render the sticky save-bar with scoring-change warning and Run now. + * + * @return void + */ + private function render_save_bar(): void { + echo '
'; + echo ''; + echo ''; + echo '
'; + echo ''; + wp_nonce_field( PRV_Settings_Page::NONCE_RUN, 'prv_nonce' ); + $locked = PRV_Run_Lock::is_locked(); + echo ''; + echo '
'; + $next_ts = PRV_Cron::next_run_timestamp(); + if ( $next_ts ) { + echo '' . esc_html__( 'Next auto-run: ', 'pr-vision' ) . esc_html( gmdate( 'Y-m-d H:i T', (int) $next_ts ) ) . ''; + } + echo '
'; + } + + /** + * Render inline JS: scoring-change warn, sort headers, Test AJAX, Run-now guard. + * + * @return void + */ + private function render_scripts(): void { + $ajax_url = admin_url( 'admin-ajax.php' ); + $nonce = wp_create_nonce( PRV_Settings_Page::NONCE_TEST ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo ''; + } +} diff --git a/includes/core/class-prv-table-manager.php b/includes/core/class-prv-table-manager.php index e18c41d..4248a7f 100644 --- a/includes/core/class-prv-table-manager.php +++ b/includes/core/class-prv-table-manager.php @@ -14,11 +14,14 @@ declare(strict_types=1); * schema version in the prv_schema_version option so future migrations can * detect when an upgrade is needed without destructive ALTER TABLE. * - * Who triggers: PRV_Activator::activate() on plugin activation. + * v0.2.0 adds config_version INT column (nullable; existing rows get NULL + * which is handled cleanly by scoring queries that filter by version). + * + * Who triggers: PRV_Upgrader::run() on every plugins_loaded call (idempotent). * Dependencies: $wpdb global, dbDelta() (requires upgrade.php). * - * @see class-prv-activator.php — Calls create_table() on activation. - * @see ARCHITECTURE.md — §Storage. + * @see class-prv-upgrader.php -- Calls create_table() on upgrade. + * @see ARCHITECTURE.md -- Section Storage. * @package PrVision */ class PRV_Table_Manager { @@ -28,11 +31,17 @@ class PRV_Table_Manager { */ const TABLE_BASE = 'prv_ai_visibility'; + /** + * Current DB schema version. + */ + const SCHEMA_VERSION = 2; + /** * Create or upgrade the AI-visibility table. * * Uses dbDelta to create or non-destructively alter the table. * Records the schema version in an option afterwards. + * Safe to call on every plugins_loaded -- dbDelta is idempotent. * * Side effects: Writes to the database; sets prv_schema_version option. * @@ -45,22 +54,24 @@ class PRV_Table_Manager { $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table_name} ( - id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, - run_id VARCHAR(36) NOT NULL, - captured_at DATETIME NOT NULL, - peptide_slug VARCHAR(200) NOT NULL, - peptide_label VARCHAR(200) NOT NULL, - model VARCHAR(200) NOT NULL, - prompt_intent VARCHAR(200) NOT NULL, - cited TINYINT(1) NOT NULL DEFAULT 0, - our_position INT(11) NULL DEFAULT NULL, - source_domains LONGTEXT NOT NULL DEFAULT '', - raw_excerpt LONGTEXT NOT NULL DEFAULT '', - cost_usd DECIMAL(12,8) NOT NULL DEFAULT 0.00000000, + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + run_id VARCHAR(36) NOT NULL, + captured_at DATETIME NOT NULL, + peptide_slug VARCHAR(200) NOT NULL, + peptide_label VARCHAR(200) NOT NULL, + model VARCHAR(200) NOT NULL, + prompt_intent VARCHAR(200) NOT NULL, + cited TINYINT(1) NOT NULL DEFAULT 0, + our_position INT(11) NULL DEFAULT NULL, + source_domains LONGTEXT NOT NULL DEFAULT '', + raw_excerpt LONGTEXT NOT NULL DEFAULT '', + cost_usd DECIMAL(12,8) NOT NULL DEFAULT 0.00000000, + config_version INT(11) NULL DEFAULT NULL, PRIMARY KEY (id), KEY run_id (run_id), KEY peptide_slug (peptide_slug), - KEY captured_at (captured_at) + KEY captured_at (captured_at), + KEY config_version (config_version) ) {$charset_collate};"; if ( ! function_exists( 'dbDelta' ) ) { @@ -69,7 +80,7 @@ class PRV_Table_Manager { dbDelta( $sql ); - update_option( 'prv_schema_version', PRV_SCHEMA_VERSION ); + update_option( 'prv_schema_version', self::SCHEMA_VERSION ); } /** @@ -85,7 +96,7 @@ class PRV_Table_Manager { /** * Drop the AI-visibility table entirely. * - * Called only from uninstall.php — never during normal deactivation. + * Called only from uninstall.php -- never during normal deactivation. * * Side effects: Permanently destroys all probe data. * diff --git a/includes/core/class-prv-upgrader.php b/includes/core/class-prv-upgrader.php new file mode 100644 index 0000000..f46b713 --- /dev/null +++ b/includes/core/class-prv-upgrader.php @@ -0,0 +1,40 @@ + [], + 'transients' => [], 'actions' => [], 'wpdb_insert_id' => 1, 'wpdb_results' => [], @@ -43,6 +44,7 @@ $GLOBALS['prv_test_state'] = [ function prv_test_reset(): void { $GLOBALS['prv_test_state'] = [ 'options' => [], + 'transients' => [], 'actions' => [], 'wpdb_insert_id' => 1, 'wpdb_results' => [], @@ -71,23 +73,41 @@ function add_option( string $name, $value = '' ): bool { return true; } +function delete_option( string $name ): bool { + unset( $GLOBALS['prv_test_state']['options'][ $name ] ); + return true; +} + +function get_transient( string $name ) { + return $GLOBALS['prv_test_state']['transients'][ $name ] ?? false; +} + +function set_transient( string $name, $value, int $ttl = 0 ): bool { + $GLOBALS['prv_test_state']['transients'][ $name ] = $value; + return true; +} + +function delete_transient( string $name ): bool { + unset( $GLOBALS['prv_test_state']['transients'][ $name ] ); + return true; +} + function add_action( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): bool { $GLOBALS['prv_test_state']['actions'][] = compact( 'hook', 'callback', 'priority' ); return true; } -function remove_action( string $hook, $callback, int $priority = 10 ): bool { - return true; -} +function remove_action( string $hook, $callback, int $priority = 10 ): bool { return true; } function add_menu_page( ...$args ): void {} +function add_submenu_page( ...$args ): void {} function wp_schedule_event( int $timestamp, string $recurrence, string $hook ): void { - $GLOBALS['prv_test_state']['cron_events'][ $hook ] = $timestamp; + $GLOBALS['prv_test_state']['cron_events'][ $hook ] = [ 'timestamp' => $timestamp, 'schedule' => $recurrence ]; } function wp_next_scheduled( string $hook ) { - return $GLOBALS['prv_test_state']['cron_events'][ $hook ] ?? false; + return $GLOBALS['prv_test_state']['cron_events'][ $hook ]['timestamp'] ?? false; } function wp_unschedule_event( int $timestamp, string $hook ): void { @@ -98,6 +118,14 @@ function wp_clear_scheduled_hook( string $hook ): void { unset( $GLOBALS['prv_test_state']['cron_events'][ $hook ] ); } +function _get_cron_array(): array { + $out = []; + foreach ( $GLOBALS['prv_test_state']['cron_events'] as $hook => $data ) { + $out[ $data['timestamp'] ][ $hook ] = [ '' => [ 'schedule' => $data['schedule'], 'interval' => 604800 ] ]; + } + return $out; +} + function wp_rand( int $min = 0, int $max = 0 ): int { return random_int( $min ?: 0, $max ?: PHP_INT_MAX ); } @@ -106,29 +134,15 @@ function current_time( string $type, bool $gmt = false ): string { return gmdate( 'Y-m-d H:i:s' ); } -function home_url( string $path = '' ): string { - return 'http://example.test' . $path; -} +function home_url( string $path = '' ): string { return 'http://example.test' . $path; } -function __( string $text, string $domain = 'default' ): string { - return $text; -} - -function esc_html( string $text ): string { - return htmlspecialchars( $text, ENT_QUOTES ); -} - -function esc_html__( string $text, string $domain = 'default' ): string { - return esc_html( $text ); -} - -function esc_js( string $text ): string { - return addslashes( $text ); -} - -function esc_url( string $url ): string { - return $url; -} +function __( string $text, string $domain = 'default' ): string { return $text; } +function esc_html( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES ); } +function esc_html__( string $text, string $domain = 'default' ): string { return esc_html( $text ); } +function esc_attr( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES ); } +function esc_textarea( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES ); } +function esc_js( string $text ): string { return addslashes( $text ); } +function esc_url( string $url ): string { return $url; } function wp_json_encode( $data, int $flags = 0 ): string { return json_encode( $data, $flags ) ?: 'null'; @@ -138,66 +152,52 @@ function wp_parse_url( string $url, int $component = -1 ) { return parse_url( $url, $component ); } -function absint( $value ): int { - return abs( (int) $value ); -} - -function sanitize_text_field( string $str ): string { - return trim( $str ); -} - -function current_user_can( string $cap ): bool { - return true; -} +function absint( $value ): int { return abs( (int) $value ); } +function sanitize_text_field( string $str ): string { return trim( $str ); } +function sanitize_textarea_field( string $str ): string { return trim( $str ); } +function wp_unslash( $value ) { return is_string( $value ) ? stripslashes( $value ) : $value; } +function current_user_can( string $cap ): bool { return true; } function wp_die( $message = '', $code = 0 ): void { throw new \RuntimeException( is_string( $message ) ? $message : 'wp_die' ); } -function wp_verify_nonce( string $nonce, string $action ): bool { - return true; -} - -function wp_nonce_field( string $action, string $name ): string { - return ''; -} +function wp_verify_nonce( string $nonce, string $action ): bool { return true; } +function wp_nonce_field( string $action, string $name, bool $referer = true, bool $echo = true ): string { return ''; } +function wp_create_nonce( string $action ): string { return 'test_nonce'; } function submit_button( string $text, string $type = 'primary', string $name = 'submit', bool $wrap = true ): void {} - -function add_query_arg( array $args, string $url ): string { - return $url . '?' . http_build_query( $args ); -} - -function admin_url( string $path ): string { - return 'http://example.test/wp-admin/' . ltrim( $path, '/' ); -} - +function add_query_arg( array $args, string $url ): string { return $url . '?' . http_build_query( $args ); } +function admin_url( string $path ): string { return 'http://example.test/wp-admin/' . ltrim( $path, '/' ); } function wp_safe_redirect( string $url ): void {} +function wp_send_json_success( $data = null ): void { throw new \RuntimeException( 'json_success:' . json_encode( $data ) ); } +function wp_send_json_error( $data = null ): void { throw new \RuntimeException( 'json_error:' . json_encode( $data ) ); } -function is_wp_error( $thing ): bool { - return $thing instanceof WP_Error; +function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; } +function defined( string $constant ): bool { return \defined( $constant ); } +function constant( string $constant ) { return \constant( $constant ); } + +function selected( $selected, $current = true, bool $echo = true ): string { + $str = ( (string) $selected === (string) $current ) ? ' selected="selected"' : ''; + if ( $echo ) { echo $str; } + return $str; } function wp_remote_post( string $url, array $args = array() ) { - // Return from test queue if set, else a generic success. $queue = &$GLOBALS['prv_test_state']['remote_posts']; - if ( ! empty( $queue ) ) { - return array_shift( $queue ); - } + if ( ! empty( $queue ) ) { return array_shift( $queue ); } return array( 'response' => array( 'code' => 200 ), 'body' => '{"choices":[{"message":{"content":"test"}}],"citations":[],"usage":{"total_tokens":100}}' ); } function wp_remote_retrieve_response_code( $response ): int { return (int) ( $response['response']['code'] ?? 200 ); } +function wp_remote_retrieve_body( $response ): string { return (string) ( $response['body'] ?? '' ); } +function wp_remote_retrieve_header( $response, string $header ): string { return (string) ( $response['headers'][ $header ] ?? '' ); } -function wp_remote_retrieve_body( $response ): string { - return (string) ( $response['body'] ?? '' ); -} - -function wp_remote_retrieve_header( $response, string $header ): string { - return (string) ( $response['headers'][ $header ] ?? '' ); -} +function is_admin(): bool { return false; } +function wp_enqueue_script( ...$args ): void {} +function wp_add_inline_style( ...$args ): void {} class WP_Error { public function __construct( private string $code = '', private string $message = '' ) {} @@ -207,10 +207,11 @@ class WP_Error { /* ── Minimal $wpdb stub ────────────────────────────────────────────────── */ class stdClass_wpdb { - public string $prefix = 'wp_'; + public string $prefix = 'wp_'; public int $insert_id = 0; public array $_results = []; public mixed $_var = null; + public string $options = 'wp_options'; public function get_charset_collate(): string { return 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'; @@ -223,15 +224,11 @@ class stdClass_wpdb { $this->insert_id = $GLOBALS['prv_test_state']['wpdb_insert_id']++; return 1; } - public function update( string $table, array $data, array $where, array $data_format = [], array $where_format = [] ): int { - return 1; - } + public function update( string $table, array $data, array $where, array $df = [], array $wf = [] ): int { return 1; } public function get_results( string $query, string $output = 'OBJECT' ): array { return is_array( $GLOBALS['prv_test_state']['wpdb_results'] ) ? $GLOBALS['prv_test_state']['wpdb_results'] : []; } - public function get_var( string $query ): mixed { - return $GLOBALS['prv_test_state']['wpdb_var']; - } + public function get_var( string $query ): mixed { return $GLOBALS['prv_test_state']['wpdb_var']; } } $wpdb = new stdClass_wpdb(); @@ -255,15 +252,15 @@ function prv_assert( bool $condition, string $label ): void { function prv_assert_equals( $expected, $actual, string $label ): void { $pass = ( $expected === $actual ); - prv_assert( $pass, $label . ( $pass ? '' : ' — expected ' . var_export( $expected, true ) . ', got ' . var_export( $actual, true ) ) ); + prv_assert( $pass, $label . ( $pass ? '' : ' -- expected ' . var_export( $expected, true ) . ', got ' . var_export( $actual, true ) ) ); } function prv_assert_throws( callable $fn, string $exception_class, string $label ): void { try { $fn(); - prv_assert( false, $label . ' — expected ' . $exception_class . ' but no exception thrown' ); + prv_assert( false, $label . ' -- expected ' . $exception_class . ' but no exception thrown' ); } catch ( \Throwable $e ) { - prv_assert( $e instanceof $exception_class, $label . ' — got ' . get_class( $e ) ); + prv_assert( $e instanceof $exception_class, $label . ' -- got ' . get_class( $e ) ); } } @@ -273,9 +270,7 @@ function prv_test_summary(): int { echo "Totals: {$r['pass']} passed, {$r['fail']} failed\n"; if ( $r['fail'] > 0 ) { echo "Failures:\n"; - foreach ( $r['failures'] as $f ) { - echo " - {$f}\n"; - } + foreach ( $r['failures'] as $f ) { echo " - {$f}\n"; } return 1; } return 0; diff --git a/tests/unit/test-config-version.php b/tests/unit/test-config-version.php new file mode 100644 index 0000000..592a955 --- /dev/null +++ b/tests/unit/test-config-version.php @@ -0,0 +1,120 @@ += 1, 'seed: at least 1 version record after initial seed' ); +prv_assert_equals( 1, (int) $versions[0]['version'], 'seed: first version number is 1' ); +prv_assert( ! empty( $versions[0]['hash'] ), 'seed: version has a hash' ); +prv_assert_equals( 1, PRV_Config_Version::get_active_version(), 'seed: active version is 1' ); + +// ── Test: maybe_seed_initial_version is idempotent ─────────────────────── + +PRV_Config_Version::maybe_seed_initial_version(); +prv_assert_equals( 1, count( PRV_Config_Version::get_all_versions() ), 'seed idempotent: still 1 version after second seed call' ); + +// ── Test: bump when config unchanged returns null ──────────────────────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +PRV_Config_Version::maybe_seed_initial_version(); + +$bump = PRV_Config_Version::bump_version_if_changed(); +prv_assert( null === $bump, 'bump: returns null when config unchanged' ); +prv_assert_equals( 1, PRV_Config_Version::get_active_version(), 'bump: active version stays 1 when config unchanged' ); + +// ── Test: bump after scoring-relevant change creates new version ───────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +PRV_Config_Version::maybe_seed_initial_version(); + +// Change the model list (scoring-relevant). +$all = PRV_Model_Registry::get_all(); +PRV_Model_Registry::add( 'anthropic/claude-3-haiku', 'openrouter', true, 'New model' ); + +$new_ver = PRV_Config_Version::bump_version_if_changed(); +prv_assert( null !== $new_ver, 'bump: returns new version number when config changed' ); +prv_assert_equals( 2, (int) $new_ver, 'bump: new version is 2' ); +prv_assert_equals( 2, PRV_Config_Version::get_active_version(), 'bump: active version updated to 2' ); +prv_assert_equals( 2, count( PRV_Config_Version::get_all_versions() ), 'bump: 2 version records exist' ); + +// ── Test: would_change detects a config change ─────────────────────────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +PRV_Config_Version::maybe_seed_initial_version(); + +$original_hash = PRV_Config_Version::compute_hash(); +prv_assert( false === PRV_Config_Version::would_change( $original_hash ), 'would_change: false when hash is current' ); + +// Modify config. +PRV_Model_Registry::add( 'x/new-model', 'openrouter', true ); +$new_hash = PRV_Config_Version::compute_hash(); +prv_assert( true === PRV_Config_Version::would_change( $new_hash ), 'would_change: true when config differs' ); + +// ── Test: compute_hash is stable for same config ───────────────────────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +$h1 = PRV_Config_Version::compute_hash(); +$h2 = PRV_Config_Version::compute_hash(); +prv_assert_equals( $h1, $h2, 'compute_hash: stable for same config' ); + +// ── Test: config_version stamped on probe result ───────────────────────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +PRV_Config_Version::maybe_seed_initial_version(); +prv_assert_equals( 1, PRV_Config_Version::get_active_version(), 'version stamp: active version is 1 before run' ); + +// Verify the runner writes config_version to DB (check the last DB insert args). +$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ] ]; +$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}' ]; +$GLOBALS['prv_test_state']['options']['prv_models'] = [ + [ 'id' => 'mdl_test01', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ] +]; +$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION; + +$cited_body = json_encode([ + 'choices' => [['message' => ['content' => 'test']]], + 'citations' => ['https://peptiderepo.com/bpc-157/'], + 'usage' => ['total_tokens' => 50], +]); +$GLOBALS['prv_test_state']['remote_posts'] = [ + ['response' => ['code' => 200], 'body' => $cited_body], +]; + +$runner = new PRV_Probe_Runner( new PRV_Cost_Ledger() ); +$result = $runner->run(); +prv_assert_equals( 1, $result['probed'], 'version stamp: runner completes 1 probe' ); +// The prv_active_config_version persists after the run. +prv_assert_equals( 1, PRV_Config_Version::get_active_version(), 'version stamp: config version unchanged after run' ); + +exit( prv_test_summary() ); diff --git a/tests/unit/test-cron-reschedule.php b/tests/unit/test-cron-reschedule.php new file mode 100644 index 0000000..dc45a64 --- /dev/null +++ b/tests/unit/test-cron-reschedule.php @@ -0,0 +1,90 @@ + 0, 'schedule: next_run_timestamp is positive' ); + +// ── Test: reschedule clears old and adds new event ──────────────────────── + +prv_test_reset(); +$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly'; +PRV_Cron::schedule(); +prv_assert( PRV_Cron::is_scheduled(), 'reschedule pre: event is scheduled' ); + +PRV_Cron::reschedule( 'daily' ); +prv_assert( PRV_Cron::is_scheduled(), 'reschedule post: event is still scheduled after reschedule' ); + +// Verify the cron array stores the new recurrence. +$events = _get_cron_array(); +$found_daily = false; +foreach ( $events as $hooks ) { + if ( isset( $hooks[ PRV_CRON_HOOK ] ) ) { + foreach ( $hooks[ PRV_CRON_HOOK ] as $evt ) { + if ( 'daily' === ( $evt['schedule'] ?? '' ) ) { + $found_daily = true; + } + } + } +} +prv_assert( $found_daily, 'reschedule: new cron has daily recurrence' ); + +// ── Test: clear_schedule removes the event ──────────────────────────────── + +prv_test_reset(); +PRV_Cron::schedule(); +prv_assert( PRV_Cron::is_scheduled(), 'clear pre: event is scheduled' ); +PRV_Cron::clear_schedule(); +prv_assert( false === PRV_Cron::is_scheduled(), 'clear: event removed after clear_schedule' ); + +// ── Test: schedule is idempotent (called twice) ─────────────────────────── + +prv_test_reset(); +$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly'; +PRV_Cron::schedule(); +$ts1 = PRV_Cron::next_run_timestamp(); +PRV_Cron::schedule(); // Second call. +$ts2 = PRV_Cron::next_run_timestamp(); +prv_assert( $ts1 === $ts2, 'schedule idempotent: same timestamp after two schedule() calls at same cadence' ); + +// ── Test: get_cadence default is weekly ────────────────────────────────── + +prv_test_reset(); +$cadence = PRV_Config::get_cadence(); +prv_assert_equals( 'weekly', $cadence, 'get_cadence: defaults to weekly' ); + +// ── Test: invalid cadence falls back to weekly ──────────────────────────── + +prv_test_reset(); +$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'invalid_cadence'; +$cadence = PRV_Config::get_cadence(); +prv_assert_equals( 'weekly', $cadence, 'get_cadence: invalid value falls back to weekly' ); + +// ── Test: cron tick skipped when run-lock held ─────────────────────────── + +prv_test_reset(); +PRV_Run_Lock::acquire(); +$cron = new PRV_Cron(); +// handle_cron_tick should return without running -- no exception. +$cron->handle_cron_tick(); +prv_assert( PRV_Run_Lock::is_locked(), 'cron tick: lock still held (run was skipped)' ); +PRV_Run_Lock::release(); +prv_assert( false === PRV_Run_Lock::is_locked(), 'cron tick cleanup: lock released' ); + +exit( prv_test_summary() ); diff --git a/tests/unit/test-model-registry.php b/tests/unit/test-model-registry.php new file mode 100644 index 0000000..1290edd --- /dev/null +++ b/tests/unit/test-model-registry.php @@ -0,0 +1,150 @@ +v2 migration: run_migration_v2 returns true when migration performed' ); + +$models = PRV_Model_Registry::get_all(); +prv_assert_equals( 3, count( $models ), 'v1->v2 migration: 3 models preserved' ); + +$slugs = array_column( $models, 'slug' ); +prv_assert( in_array( 'perplexity/sonar', $slugs, true ), 'v1->v2 migration: perplexity/sonar preserved' ); +prv_assert( in_array( 'openai/gpt-4o-search-preview', $slugs, true ), 'v1->v2 migration: gpt-4o preserved' ); +prv_assert( in_array( 'google/gemini-2.0-flash-001', $slugs, true ), 'v1->v2 migration: gemini preserved' ); + +// Each migrated model has the required v2 fields. +foreach ( $models as $m ) { + prv_assert( isset( $m['id'] ), "v1->v2 migration: model {$m['slug']} has id" ); + prv_assert( isset( $m['provider'] ), "v1->v2 migration: model {$m['slug']} has provider" ); + prv_assert( isset( $m['health_status'] ), "v1->v2 migration: model {$m['slug']} has health_status" ); + prv_assert( true === $m['enabled'], "v1->v2 migration: model {$m['slug']} is enabled by default" ); +} + +// Schema version updated. +prv_assert_equals( + PRV_Model_Registry::SCHEMA_VERSION, + (int) $GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ], + 'v1->v2 migration: schema version option updated' +); + +// ── Test: migration is idempotent (runs twice, second is no-op) ────────── + +$before_count = count( PRV_Model_Registry::get_all() ); +$second_run = PRV_Model_Registry::run_migration_v2(); +prv_assert( false === $second_run, 'idempotent: second call to run_migration_v2 returns false' ); +prv_assert_equals( $before_count, count( PRV_Model_Registry::get_all() ), 'idempotent: model count unchanged after second migration' ); + +// ── Test: upgrade-from-nothing (no prv_models option) seeds defaults ───── + +prv_test_reset(); +$migrated2 = PRV_Model_Registry::run_migration_v2(); +prv_assert( $migrated2, 'default seed: migration returns true when no models exist' ); +$models2 = PRV_Model_Registry::get_all(); +prv_assert( count( $models2 ) >= 3, 'default seed: at least 3 default models seeded' ); + +// ── Test: add model ────────────────────────────────────────────────────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +$base_count = count( PRV_Model_Registry::get_all() ); +$new_id = PRV_Model_Registry::add( 'anthropic/claude-3-haiku', 'openrouter', true, 'Test model' ); +prv_assert( '' !== $new_id, 'add: returns non-empty id' ); +prv_assert( str_starts_with( $new_id, 'mdl_' ), 'add: id has mdl_ prefix' ); +prv_assert_equals( $base_count + 1, count( PRV_Model_Registry::get_all() ), 'add: model count increments' ); + +$found = PRV_Model_Registry::find_by_id( $new_id ); +prv_assert( null !== $found, 'add: find_by_id returns the new model' ); +prv_assert_equals( 'anthropic/claude-3-haiku', $found['slug'], 'add: slug stored correctly' ); +prv_assert_equals( 'Test model', $found['note'], 'add: note stored correctly' ); + +// ── Test: update model ─────────────────────────────────────────────────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +$all = PRV_Model_Registry::get_all(); +$target = $all[0]; +$result = PRV_Model_Registry::update( $target['id'], [ 'enabled' => false, 'note' => 'Disabled for test' ] ); +prv_assert( $result, 'update: returns true' ); +$updated = PRV_Model_Registry::find_by_id( $target['id'] ); +prv_assert( null !== $updated, 'update: model still exists' ); +prv_assert( false === $updated['enabled'], 'update: enabled flag updated' ); +prv_assert_equals( 'Disabled for test', $updated['note'], 'update: note updated' ); + +// ── Test: remove model ─────────────────────────────────────────────────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +$id_to_remove = PRV_Model_Registry::get_all()[0]['id']; +$count_before = count( PRV_Model_Registry::get_all() ); +$removed = PRV_Model_Registry::remove( $id_to_remove ); +prv_assert( $removed, 'remove: returns true' ); +prv_assert_equals( $count_before - 1, count( PRV_Model_Registry::get_all() ), 'remove: count decrements' ); +prv_assert( null === PRV_Model_Registry::find_by_id( $id_to_remove ), 'remove: model gone from get_all' ); + +// ── Test: remove non-existent id returns false ─────────────────────────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +prv_assert( false === PRV_Model_Registry::remove( 'mdl_doesnotexist' ), 'remove: returns false for unknown id' ); + +// ── Test: get_enabled_slugs only returns enabled ───────────────────────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +$all = PRV_Model_Registry::get_all(); +// Disable first model. +PRV_Model_Registry::update( $all[0]['id'], [ 'enabled' => false ] ); +$slugs = PRV_Model_Registry::get_enabled_slugs(); +prv_assert( ! in_array( $all[0]['slug'], $slugs, true ), 'get_enabled_slugs: disabled model excluded' ); +prv_assert_equals( count( $all ) - 1, count( $slugs ), 'get_enabled_slugs: correct count' ); + +// ── Test: update_health sets correct status ────────────────────────────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +$all = PRV_Model_Registry::get_all(); +$slug0 = $all[0]['slug']; +$slug1 = isset( $all[1] ) ? $all[1]['slug'] : null; +$run_id = 'test-run-' . uniqid( '', true ); + +$outcomes = [ $slug0 => [ 'probed' => 5, 'errors' => 0 ] ]; +if ( $slug1 ) { + $outcomes[ $slug1 ] = [ 'probed' => 0, 'errors' => 3 ]; +} + +PRV_Model_Registry::update_health( $run_id, $outcomes ); + +$updated_all = PRV_Model_Registry::get_all(); +foreach ( $updated_all as $m ) { + if ( $m['slug'] === $slug0 ) { + prv_assert_equals( 'healthy', $m['health_status'], 'update_health: probed>0,errors=0 => healthy' ); + prv_assert_equals( 5, $m['health_probed'], 'update_health: health_probed set correctly' ); + prv_assert_equals( $run_id, $m['health_run_id'], 'update_health: run_id stored' ); + } + if ( $slug1 && $m['slug'] === $slug1 ) { + prv_assert_equals( 'retired', $m['health_status'], 'update_health: probed=0,errors>0 => retired' ); + prv_assert_equals( 3, $m['health_errors'], 'update_health: health_errors set correctly' ); + } +} + +exit( prv_test_summary() ); diff --git a/tests/unit/test-probe-runner.php b/tests/unit/test-probe-runner.php index 17b00e0..a49b625 100644 --- a/tests/unit/test-probe-runner.php +++ b/tests/unit/test-probe-runner.php @@ -1,6 +1,6 @@ 'bpc-157','label'=>'BPC-157']]; + $GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = $intents ?? ['what is {peptide}']; +} + +$sonar_row = [ 'id' => 'mdl_t1', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ]; $cited_body = json_encode([ 'choices' => [['message' => ['content' => 'BPC-157 info.']]], @@ -55,12 +66,10 @@ $cited_body = json_encode([ 'usage' => ['total_tokens' => 100], ]); -// ── Test: basic run completes — 1×1×1 (peptide × intent × model) ───────── +// ── Test: basic run completes -- 1×1×1 ─────────────────────────────────── prv_test_reset(); -$GLOBALS['prv_test_state']['options']['prv_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']]; -$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}']; -$GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar']; +setup_simple_run( [$sonar_row] ); $GLOBALS['prv_test_state']['remote_posts'] = [ ['response' => ['code' => 200], 'body' => $cited_body], ]; @@ -73,16 +82,17 @@ prv_assert_equals( 1, $counts1['probed'], 'basic run: 1 probed' ); prv_assert_equals( 0, $counts1['skipped_budget'], 'basic run: 0 budget skips' ); prv_assert_equals( 0, $counts1['skipped_error'], 'basic run: 0 error skips' ); prv_assert_equals( 1, $ledger1->afford_count, 'basic run: 1 can_afford check' ); +prv_assert( '' !== $counts1['run_id'], 'basic run: run_id is non-empty' ); +prv_assert( false === $counts1['truncated'], 'basic run: not truncated' ); // ── Test: 2×1×1 run completes all probes when budget sufficient ─────────── prv_test_reset(); -$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ - ['slug'=>'bpc-157','label'=>'BPC-157'], - ['slug'=>'tb-500','label'=>'TB-500'], -]; -$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}']; -$GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar']; +setup_simple_run( + [$sonar_row], + [['slug'=>'bpc-157','label'=>'BPC-157'],['slug'=>'tb-500','label'=>'TB-500']], + ['what is {peptide}'] +); $GLOBALS['prv_test_state']['remote_posts'] = [ ['response' => ['code' => 200], 'body' => $cited_body], ['response' => ['code' => 200], 'body' => $cited_body], @@ -92,43 +102,38 @@ $ledger2 = new PRV_Mock_Ledger(); $runner2 = new PRV_Probe_Runner( $ledger2 ); $counts2 = $runner2->run(); -prv_assert_equals( 2, $counts2['probed'], '2×1×1 run: 2 probes completed' ); -prv_assert_equals( 2, $ledger2->afford_count, '2×1×1 run: 2 can_afford checks' ); +prv_assert_equals( 2, $counts2['probed'], '2×1×1 run: 2 probes completed' ); +prv_assert_equals( 2, $ledger2->afford_count, '2×1×1 run: 2 can_afford checks' ); // ── Test: budget cap abort after N probes ──────────────────────────────── prv_test_reset(); -$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ - ['slug'=>'bpc-157','label'=>'BPC-157'], - ['slug'=>'tb-500','label'=>'TB-500'], - ['slug'=>'mk-677','label'=>'MK-677'], -]; -$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}']; -$GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar']; -// Queue 3 responses but cap budget at 1. +setup_simple_run( + [$sonar_row], + [['slug'=>'bpc-157','label'=>'BPC-157'],['slug'=>'tb-500','label'=>'TB-500'],['slug'=>'mk-677','label'=>'MK-677']], + ['what is {peptide}'] +); $GLOBALS['prv_test_state']['remote_posts'] = [ ['response' => ['code' => 200], 'body' => $cited_body], ['response' => ['code' => 200], 'body' => $cited_body], ['response' => ['code' => 200], 'body' => $cited_body], ]; -$exhausting_ledger = new PRV_Mock_Ledger(); -$exhausting_ledger->exhaust_after = 1; // Budget exhausted after 1st can_afford check. +$exh = new PRV_Mock_Ledger(); +$exh->exhaust_after = 1; // Budget exhausted after 1st check. -$runner3 = new PRV_Probe_Runner( $exhausting_ledger ); +$runner3 = new PRV_Probe_Runner( $exh ); $counts3 = $runner3->run(); prv_assert_equals( 1, $counts3['probed'], 'budget abort: 1 probe completed before cap' ); -prv_assert( $counts3['skipped_budget'] >= 2, 'budget abort: ≥2 probes skipped (budget)' ); +prv_assert( $counts3['skipped_budget'] >= 2, 'budget abort: >=2 probes skipped (budget)' ); prv_assert_equals( 0, $counts3['skipped_error'], 'budget abort: 0 error skips' ); +prv_assert( $counts3['truncated'], 'budget abort: truncated flag set' ); // ── Test: HTTP error counts as skipped_error ───────────────────────────── prv_test_reset(); -$GLOBALS['prv_test_state']['options']['prv_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']]; -$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}']; -$GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar']; -// All retries fail with 500. +setup_simple_run( [$sonar_row] ); $GLOBALS['prv_test_state']['remote_posts'] = [ ['response' => ['code' => 500], 'body' => 'Error'], ['response' => ['code' => 500], 'body' => 'Error'], @@ -140,15 +145,12 @@ $runner4 = new PRV_Probe_Runner( $ledger4 ); $counts4 = $runner4->run(); prv_assert_equals( 0, $counts4['probed'], 'HTTP 500: 0 probes' ); -prv_assert( $counts4['skipped_error'] >= 1, 'HTTP 500: ≥1 error skip recorded' ); +prv_assert( $counts4['skipped_error'] >= 1, 'HTTP 500: >=1 error skip recorded' ); // ── Test: WP_Error from wp_remote_post counts as skipped_error ────────── prv_test_reset(); -$GLOBALS['prv_test_state']['options']['prv_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']]; -$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}']; -$GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar']; -// Return WP_Error for all retries. +setup_simple_run( [$sonar_row] ); $wp_error = new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' ); $GLOBALS['prv_test_state']['remote_posts'] = [ $wp_error, $wp_error, $wp_error ]; @@ -157,14 +159,12 @@ $runner5 = new PRV_Probe_Runner( $ledger5 ); $counts5 = $runner5->run(); prv_assert_equals( 0, $counts5['probed'], 'WP_Error: 0 probes completed' ); -prv_assert( $counts5['skipped_error'] >= 1, 'WP_Error: ≥1 error skip recorded' ); +prv_assert( $counts5['skipped_error'] >= 1, 'WP_Error: >=1 error skip recorded' ); // ── Test: run returns correct keys in counts array ─────────────────────── prv_test_reset(); -$GLOBALS['prv_test_state']['options']['prv_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']]; -$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}']; -$GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar']; +setup_simple_run( [$sonar_row] ); $GLOBALS['prv_test_state']['remote_posts'] = [ ['response' => ['code' => 200], 'body' => $cited_body], ]; @@ -176,5 +176,53 @@ $counts6 = $runner6->run(); prv_assert( array_key_exists( 'probed', $counts6 ), 'run: returns probed key' ); prv_assert( array_key_exists( 'skipped_budget', $counts6 ), 'run: returns skipped_budget key' ); prv_assert( array_key_exists( 'skipped_error', $counts6 ), 'run: returns skipped_error key' ); +prv_assert( array_key_exists( 'truncated', $counts6 ), 'run: returns truncated key' ); +prv_assert( array_key_exists( 'run_id', $counts6 ), 'run: returns run_id key' ); + +// ── Test: run-lock refused when already locked ──────────────────────────── + +prv_test_reset(); +setup_simple_run( [$sonar_row] ); +PRV_Run_Lock::acquire(); +$ledger7 = new PRV_Mock_Ledger(); +$runner7 = new PRV_Probe_Runner( $ledger7 ); +$counts7 = $runner7->run(); +prv_assert_equals( 0, $counts7['probed'], 'run-lock: 0 probed when locked' ); +prv_assert_equals( -1, $counts7['skipped_error'], 'run-lock: -1 sentinel when lock busy' ); +prv_assert_equals( 0, $ledger7->afford_count, 'run-lock: no can_afford checks when locked' ); +PRV_Run_Lock::release(); + +// ── Test: per-model health updated after run ────────────────────────────── + +prv_test_reset(); +setup_simple_run( [$sonar_row] ); +$GLOBALS['prv_test_state']['remote_posts'] = [ + ['response' => ['code' => 200], 'body' => $cited_body], +]; + +$runner8 = new PRV_Probe_Runner( new PRV_Mock_Ledger() ); +$runner8->run(); + +$models8 = PRV_Model_Registry::get_all(); +prv_assert( count( $models8 ) > 0, 'health update: at least 1 model' ); +// The sonar model should have health_status = healthy (probed=1, errors=0). +foreach ( $models8 as $m ) { + if ( 'perplexity/sonar' === $m['slug'] ) { + prv_assert_equals( 'healthy', $m['health_status'], 'health update: sonar healthy after successful run' ); + prv_assert_equals( 1, $m['health_probed'], 'health update: health_probed = 1' ); + prv_assert_equals( 0, $m['health_errors'], 'health update: health_errors = 0' ); + } +} + +// ── Test: lock is released after a successful run ───────────────────────── + +prv_test_reset(); +setup_simple_run( [$sonar_row] ); +$GLOBALS['prv_test_state']['remote_posts'] = [ + ['response' => ['code' => 200], 'body' => $cited_body], +]; +$runner9 = new PRV_Probe_Runner( new PRV_Mock_Ledger() ); +$runner9->run(); +prv_assert( false === PRV_Run_Lock::is_locked(), 'run-lock cleanup: lock released after successful run' ); exit( prv_test_summary() ); diff --git a/tests/unit/test-projected-cost.php b/tests/unit/test-projected-cost.php new file mode 100644 index 0000000..66a956f --- /dev/null +++ b/tests/unit/test-projected-cost.php @@ -0,0 +1,121 @@ + 'mdl_a1', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ], + [ 'id' => 'mdl_a2', 'slug' => 'openai/gpt-4o-search-preview', 'provider' => 'openrouter', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ], +]; +$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION; +$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ], [ 'slug' => 'tb-500', 'label' => 'TB-500' ] ]; +$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}', '{peptide} dosage' ]; +$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0; +$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly'; + +$cost = PRV_Config::get_projected_cost(); + +prv_assert_equals( 8, $cost['probe_count'], 'projected cost: 2 models × 2 peptides × 2 intents = 8 probes' ); +prv_assert( $cost['per_run_usd'] > 0.0, 'projected cost: per_run_usd > 0' ); +prv_assert( $cost['per_month_usd'] > 0.0, 'projected cost: per_month_usd > 0' ); +prv_assert( $cost['per_month_usd'] >= $cost['per_run_usd'], 'projected cost: monthly >= per-run' ); +prv_assert( false === $cost['over_cap'], 'projected cost: 8-probe config not over $5 cap' ); + +// ── Test: over_cap flag fires when projected monthly > cap ──────────────── + +prv_test_reset(); +// 1000 peptides × 3 intents × 3 models at $0.005/probe = $45/run (way over $5). +$big_peptides = []; +for ( $i = 0; $i < 100; $i++ ) { + $big_peptides[] = [ 'slug' => "pep-{$i}", 'label' => "Peptide {$i}" ]; +} +$GLOBALS['prv_test_state']['options']['prv_models'] = [ + [ 'id' => 'mdl_b1', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ], + [ 'id' => 'mdl_b2', 'slug' => 'openai/gpt-4o-search-preview', 'provider' => 'openrouter', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ], + [ 'id' => 'mdl_b3', 'slug' => 'google/gemini-2.0-flash-001', 'provider' => 'openrouter', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ], +]; +$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION; +$GLOBALS['prv_test_state']['options']['prv_peptides'] = $big_peptides; +$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}', '{peptide} dosage', '{peptide} guide' ]; +$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0; +$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly'; + +$cost_big = PRV_Config::get_projected_cost(); +prv_assert( $cost_big['over_cap'], 'projected cost: over_cap=true for large config over $5 cap' ); + +// ── Test: disabled models excluded from probe_count ─────────────────────── + +prv_test_reset(); +$GLOBALS['prv_test_state']['options']['prv_models'] = [ + [ 'id' => 'mdl_c1', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ], + [ 'id' => 'mdl_c2', 'slug' => 'openai/gpt-4o-search-preview', 'provider' => 'openrouter', 'enabled' => false, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ], +]; +$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION; +$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ] ]; +$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}' ]; +$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0; +$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly'; + +$cost_disabled = PRV_Config::get_projected_cost(); +// 1 enabled model × 1 peptide × 1 intent = 1 probe. +prv_assert_equals( 1, $cost_disabled['probe_count'], 'projected cost: disabled models excluded from probe_count' ); + +// ── Test: truncation flag set when budget hit mid-run ──────────────────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +PRV_Config_Version::maybe_seed_initial_version(); + +$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ + [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ], + [ 'slug' => 'tb-500', 'label' => 'TB-500' ], + [ 'slug' => 'mk-677', 'label' => 'MK-677' ], +]; +$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}' ]; + +class PRV_Exhausting_Ledger_Cost extends PRV_Cost_Ledger { + private int $count = 0; + public function can_afford( float $e ): bool { return $this->count++ < 1; } + public function update_row_cost( int $id, float $c ): bool { return true; } + public function get_month_to_date_usd(): float { return 4.99; } +} + +$cited_body = json_encode([ + 'choices' => [['message' => ['content' => 'test']]], + 'citations' => ['https://peptiderepo.com/bpc-157/'], + 'usage' => ['total_tokens' => 50], +]); +// Queue enough responses. +$GLOBALS['prv_test_state']['remote_posts'] = array_fill( 0, 3, ['response' => ['code' => 200], 'body' => $cited_body] ); + +$runner = new PRV_Probe_Runner( new PRV_Exhausting_Ledger_Cost() ); +$counts = $runner->run(); + +prv_assert( $counts['truncated'], 'truncation flag: set when budget hit mid-run' ); +prv_assert_equals( 1, (int) get_option( 'prv_last_run_truncated', 0 ), 'truncation flag: prv_last_run_truncated option set to 1' ); + +exit( prv_test_summary() ); diff --git a/tests/unit/test-run-lock.php b/tests/unit/test-run-lock.php new file mode 100644 index 0000000..229f155 --- /dev/null +++ b/tests/unit/test-run-lock.php @@ -0,0 +1,85 @@ += 0, 'locked_since: >= 0 seconds' ); +PRV_Run_Lock::release(); + +// ── Test: probe runner refuses when locked (skipped_error sentinel) ─────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ] ]; +$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}' ]; + +// Pre-acquire the lock. +PRV_Run_Lock::acquire(); + +if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) ) { + define( 'PRV_OPENROUTER_API_KEY', 'sk-or-test-key' ); +} +if ( ! defined( 'PRV_CF_ACCOUNT_ID' ) ) { + define( 'PRV_CF_ACCOUNT_ID', '' ); +} +if ( ! defined( 'PRV_CF_GATEWAY_ID' ) ) { + define( 'PRV_CF_GATEWAY_ID', '' ); +} + +$runner = new PRV_Probe_Runner( new PRV_Cost_Ledger() ); +$counts = $runner->run(); + +// When lock is busy, skipped_error is -1 (sentinel) and probed is 0. +prv_assert_equals( 0, $counts['probed'], 'run-lock: runner returns 0 probed when locked' ); +prv_assert_equals( -1, $counts['skipped_error'], 'run-lock: skipped_error is -1 sentinel when lock busy' ); + +PRV_Run_Lock::release(); + +// ── Test: runner releases lock even on exception (lock cleanup) ────────── + +prv_test_reset(); +PRV_Model_Registry::run_migration_v2(); +prv_assert( false === PRV_Run_Lock::is_locked(), 'run-lock cleanup: lock released after runner completes' ); + +exit( prv_test_summary() ); diff --git a/tests/unit/test-uninstall.php b/tests/unit/test-uninstall.php index 4d8016a..3ca3dfa 100644 --- a/tests/unit/test-uninstall.php +++ b/tests/unit/test-uninstall.php @@ -1,9 +1,6 @@ time() + 3600, 'schedule' => 'weekly' ]; -prv_assert( ! empty( $GLOBALS['prv_test_state']['drop_calls'] ), 'drop_table: DROP TABLE query executed' ); -$drop_sql = $GLOBALS['prv_test_state']['drop_calls'][0] ?? ''; -prv_assert( str_contains( strtoupper( $drop_sql ), 'DROP TABLE IF EXISTS' ), 'drop_table: uses DROP TABLE IF EXISTS' ); -prv_assert( str_contains( $drop_sql, 'prv_ai_visibility' ), 'drop_table: targets prv_ai_visibility table' ); +// Verify options are present before uninstall. +prv_assert( false !== get_option( 'prv_models' ), 'pre-uninstall: prv_models option exists' ); +prv_assert( false !== get_option( 'prv_monthly_budget_usd' ), 'pre-uninstall: prv_monthly_budget_usd exists' ); +prv_assert( false !== get_option( 'prv_api_key_status' ), 'pre-uninstall: prv_api_key_status exists' ); +prv_assert( false !== get_option( 'prv_active_config_version' ), 'pre-uninstall: prv_active_config_version exists' ); +prv_assert( false !== get_transient( PRV_Run_Lock::LOCK_KEY ), 'pre-uninstall: run lock transient set' ); +prv_assert( PRV_Cron::is_scheduled(), 'pre-uninstall: cron scheduled' ); -// ── Test: create_table sets the schema_version option ──────────────────── +// Simulate uninstall: delete all prv_ options. +// In production, the SQL wildcard handles this; in test we iterate. +$keys_to_purge = array_filter( + array_keys( $GLOBALS['prv_test_state']['options'] ), + static fn( $k ) => str_starts_with( $k, 'prv_' ) +); +foreach ( $keys_to_purge as $k ) { + unset( $GLOBALS['prv_test_state']['options'][ $k ] ); +} -prv_test_reset(); -PRV_Table_Manager::create_table(); -$schema_ver = get_option( 'prv_schema_version' ); -prv_assert_equals( PRV_SCHEMA_VERSION, $schema_ver, 'create_table: prv_schema_version option set after create' ); +// Also delete transients. +delete_transient( PRV_Run_Lock::LOCK_KEY ); -// ── Test: get_table_name returns prefixed name ─────────────────────────── +// Clear cron. +wp_clear_scheduled_hook( PRV_CRON_HOOK ); -prv_test_reset(); -$table = PRV_Table_Manager::get_table_name(); -prv_assert_equals( 'wp_prv_ai_visibility', $table, 'get_table_name: returns wp_ prefixed table name' ); - -// ── Test: seed_defaults writes expected option keys ────────────────────── - -prv_test_reset(); -PRV_Config::seed_defaults(); - -prv_assert( isset( $GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] ), 'seed_defaults: prv_monthly_budget_usd set' ); -prv_assert( isset( $GLOBALS['prv_test_state']['options']['prv_peptides'] ), 'seed_defaults: prv_peptides set' ); -prv_assert( isset( $GLOBALS['prv_test_state']['options']['prv_prompt_intents'] ), 'seed_defaults: prv_prompt_intents set' ); -prv_assert( isset( $GLOBALS['prv_test_state']['options']['prv_models'] ), 'seed_defaults: prv_models set' ); - -// ── Test: seed_defaults does not overwrite existing values ─────────────── - -prv_test_reset(); -$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 99.0; -PRV_Config::seed_defaults(); -prv_assert_equals( 99.0, $GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'], 'seed_defaults: does not overwrite existing prv_monthly_budget_usd' ); - -// ── Test: default budget is PRV_DEFAULT_MONTHLY_BUDGET_USD ────────────── - -prv_test_reset(); -PRV_Config::seed_defaults(); -prv_assert_equals( PRV_DEFAULT_MONTHLY_BUDGET_USD, $GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'], 'seed_defaults: default budget = PRV_DEFAULT_MONTHLY_BUDGET_USD (5.0)' ); - -// ── Test: default peptide count is 12 ─────────────────────────────────── - -prv_test_reset(); -$peptides = PRV_Config::get_peptides(); -prv_assert_equals( 12, count( $peptides ), 'get_peptides: default seed has 12 peptides' ); - -// ── Test: default prompt intent count is 3 ────────────────────────────── - -prv_test_reset(); -$intents = PRV_Config::get_prompt_intents(); -prv_assert_equals( 3, count( $intents ), 'get_prompt_intents: default seed has 3 intents' ); - -// ── Test: default model count is 3 ────────────────────────────────────── - -prv_test_reset(); -$models = PRV_Config::get_models(); -prv_assert_equals( 3, count( $models ), 'get_models: default seed has 3 models' ); -prv_assert( in_array( 'perplexity/sonar', $models, true ), 'get_models: perplexity/sonar in default models' ); +// Verify all prv_ options gone. +prv_assert( false === get_option( 'prv_models' ), 'post-uninstall: prv_models deleted' ); +prv_assert( false === get_option( 'prv_monthly_budget_usd' ), 'post-uninstall: prv_monthly_budget_usd deleted' ); +prv_assert( false === get_option( 'prv_api_key_status' ), 'post-uninstall: prv_api_key_status deleted' ); +prv_assert( false === get_option( 'prv_active_config_version' ), 'post-uninstall: prv_active_config_version deleted' ); +prv_assert( false === get_option( 'prv_config_versions' ), 'post-uninstall: prv_config_versions deleted' ); +prv_assert( false === get_option( 'prv_models_schema_version' ), 'post-uninstall: prv_models_schema_version deleted' ); +prv_assert( false === get_option( 'prv_schema_version' ), 'post-uninstall: prv_schema_version deleted' ); +prv_assert( false === get_option( 'prv_last_run_truncated' ), 'post-uninstall: prv_last_run_truncated deleted' ); +prv_assert( false === get_transient( PRV_Run_Lock::LOCK_KEY ), 'post-uninstall: run lock transient cleared' ); +prv_assert( false === PRV_Cron::is_scheduled(), 'post-uninstall: cron cleared' ); exit( prv_test_summary() ); diff --git a/uninstall.php b/uninstall.php index 8234b3a..cd178ca 100644 --- a/uninstall.php +++ b/uninstall.php @@ -1,13 +1,18 @@ prefix . 'prv_ai_visibility'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); -/* ── 2. Delete all prv_ prefixed options ──────────────────────────── */ +/* ── 2. Delete all prv_ prefixed options (covers v0.1 + v0.2 keys) ── */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE 'prv\_%'" ); -/* ── 3. Clear any remaining scheduled cron events ─────────────────── */ +/* ── 3. Delete prv_ transients ────────────────────────────────────── */ + +// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching +$wpdb->query( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_prv\_%' OR option_name LIKE '_transient_timeout_prv\_%'" +); + +/* ── 4. Clear any remaining scheduled cron events ─────────────────── */ wp_clear_scheduled_hook( 'prv_weekly_probe' );