v0.3.2 — adopt estate CI: PHPUnit suite + reusable pipeline + governance workflows
- Convert 17 hand-rolled tests to PHPUnit TestCases (tests: stubs pattern) P0 security tests preserved: key-leak/encryption (KeyStoreTest), capture-allowlist/executor-path sentinel (CaptureWriterTest) - Add phpunit.xml.dist + update tests/bootstrap.php (Composer autoloader first) - Add phpunit/phpunit ^9.6 + yoast/phpunit-polyfills ^2.0 to require-dev - composer test -> vendor/bin/phpunit --configuration phpunit.xml.dist - ci.yml: thin caller of peptide-e2e reusable CI (tests: stubs, has_js: true, workflow_call trigger, permissions: contents: write) - deploy.yml: estate pattern caller of peptide-e2e deploy-app.yml@main (target_path: wp-content/plugins/pr-vision, gated on reusable CI) Pull-deploy on Hostinger stays as break-glass - Add cto-review.yml (Haiku-only, A/B panel dropped 2026-06-12) - Add main-push-audit.yml (direct-push tripwire) Agent-Session: claude-adopt-ci-20260616
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
<?php
|
||||
/**
|
||||
* Minimal test bootstrap -- stubs all WordPress functions used by the plugin
|
||||
* so classes can be exercised in plain PHP without a WP install.
|
||||
* PHPUnit bootstrap for PR Vision tests.
|
||||
*
|
||||
* Pattern mirrors peptide-repo-core's bootstrap (flat PHP, no PHPUnit).
|
||||
* Loads WP stubs in plain PHP (no WP install required) and registers
|
||||
* the plugin autoloader so all classes are available to PHPUnit test cases.
|
||||
*
|
||||
* Pattern: WP stubs (tests: stubs) — no DB, no service, fast.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Composer autoloader must come first so PHPUnit classes are available.
|
||||
require_once dirname( __DIR__ ) . '/vendor/autoload.php';
|
||||
|
||||
/* ── Constants ────────────────────────────────────────────────────────── */
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
@@ -27,7 +32,7 @@ if ( ! defined( 'ARRAY_N' ) ) {
|
||||
define( 'ARRAY_N', 'ARRAY_N' );
|
||||
}
|
||||
|
||||
define( 'PRV_VERSION', '0.3.1' );
|
||||
define( 'PRV_VERSION', '0.3.2' );
|
||||
define( 'PRV_PLUGIN_FILE', __DIR__ . '/../pr-vision.php' );
|
||||
define( 'PRV_PLUGIN_DIR', realpath( __DIR__ . '/..' ) . '/' );
|
||||
define( 'PRV_PLUGIN_URL', 'http://example.test/wp-content/plugins/pr-vision/' );
|
||||
@@ -270,47 +275,6 @@ $wpdb = new stdClass_wpdb();
|
||||
|
||||
function dbDelta( string $sql ): array { return []; }
|
||||
|
||||
/* ── Test assertion helpers ────────────────────────────────────────────── */
|
||||
|
||||
$GLOBALS['prv_test_report'] = [ 'pass' => 0, 'fail' => 0, 'failures' => [] ];
|
||||
|
||||
function prv_assert( bool $condition, string $label ): void {
|
||||
if ( $condition ) {
|
||||
$GLOBALS['prv_test_report']['pass']++;
|
||||
echo " PASS: {$label}\n";
|
||||
} else {
|
||||
$GLOBALS['prv_test_report']['fail']++;
|
||||
$GLOBALS['prv_test_report']['failures'][] = $label;
|
||||
echo " FAIL: {$label}\n";
|
||||
}
|
||||
}
|
||||
|
||||
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 ) ) );
|
||||
}
|
||||
|
||||
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' );
|
||||
} catch ( \Throwable $e ) {
|
||||
prv_assert( $e instanceof $exception_class, $label . ' -- got ' . get_class( $e ) );
|
||||
}
|
||||
}
|
||||
|
||||
function prv_test_summary(): int {
|
||||
$r = $GLOBALS['prv_test_report'];
|
||||
echo "\n---\n";
|
||||
echo "Totals: {$r['pass']} passed, {$r['fail']} failed\n";
|
||||
if ( $r['fail'] > 0 ) {
|
||||
echo "Failures:\n";
|
||||
foreach ( $r['failures'] as $f ) { echo " - {$f}\n"; }
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Load autoloader + all plugin classes ──────────────────────────────── */
|
||||
|
||||
require_once __DIR__ . '/../includes/core/class-prv-autoloader.php';
|
||||
|
||||
179
tests/unit/CaptureWriterTest.php
Normal file
179
tests/unit/CaptureWriterTest.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests: PRV_Capture_Writer — metadata + I/O writes and security assertions.
|
||||
*
|
||||
* P0 SECURITY tests (preserved from v0.2.3 + v0.3.0):
|
||||
* - Direct-writer path: 401 error record must not store "Bearer",
|
||||
* "sk-or-", or "Authorization" in any stored row.
|
||||
* - Full executor path: sentinel key must not leak through
|
||||
* PRV_Probe_Runner → PRV_Probe_Run_Executor end-to-end.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers PRV_Capture_Writer
|
||||
*/
|
||||
class CaptureWriterTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
}
|
||||
|
||||
// ── Test 1: write_meta writes a row to prv_call_meta ─────────────────
|
||||
|
||||
public function test_write_meta_inserts_one_row(): void {
|
||||
$writer = new PRV_Capture_Writer();
|
||||
$call_id = $writer->write_meta(
|
||||
array(
|
||||
'visibility_row' => 10,
|
||||
'run_id' => 'abc-123',
|
||||
'peptide_slug' => 'bpc-157',
|
||||
'model' => 'perplexity/sonar',
|
||||
'intent_label' => 'what is {peptide}',
|
||||
'tokens_in' => 100,
|
||||
'tokens_out' => 250,
|
||||
'cost_usd' => 0.00123,
|
||||
'latency_ms' => 800,
|
||||
'cited' => 1,
|
||||
'http_status' => 200,
|
||||
'config_version' => 3,
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertGreaterThan( 0, $call_id, 'write_meta returns a positive row ID' );
|
||||
|
||||
$meta_rows = $GLOBALS['prv_test_state']['wpdb_call_meta_rows'];
|
||||
$this->assertCount( 1, $meta_rows, 'write_meta inserts exactly one call_meta row' );
|
||||
$this->assertSame( 'bpc-157', $meta_rows[0]['peptide_slug'], 'write_meta stores peptide_slug' );
|
||||
$this->assertSame( 100, $meta_rows[0]['tokens_in'], 'write_meta stores tokens_in' );
|
||||
$this->assertSame( 250, $meta_rows[0]['tokens_out'], 'write_meta stores tokens_out' );
|
||||
$this->assertSame( 0, $meta_rows[0]['io_captured'], 'write_meta sets io_captured=0 initially' );
|
||||
}
|
||||
|
||||
// ── Test 2: write_io writes a row to prv_call_io ─────────────────────
|
||||
|
||||
public function test_write_io_inserts_one_row(): void {
|
||||
$writer = new PRV_Capture_Writer();
|
||||
$call_id = $writer->write_meta(
|
||||
array(
|
||||
'visibility_row' => 10,
|
||||
'run_id' => 'abc-123',
|
||||
'peptide_slug' => 'bpc-157',
|
||||
'model' => 'perplexity/sonar',
|
||||
'intent_label' => 'what is {peptide}',
|
||||
'tokens_in' => 100,
|
||||
'tokens_out' => 250,
|
||||
'cost_usd' => 0.00123,
|
||||
'latency_ms' => 800,
|
||||
'cited' => 1,
|
||||
'http_status' => 200,
|
||||
'config_version' => 3,
|
||||
)
|
||||
);
|
||||
$ok = $writer->write_io( $call_id, 'what is BPC-157?', 'BPC-157 is a peptide...' );
|
||||
|
||||
$this->assertTrue( $ok, 'write_io returns true on success' );
|
||||
|
||||
$io_rows = $GLOBALS['prv_test_state']['wpdb_call_io_rows'];
|
||||
$this->assertCount( 1, $io_rows, 'write_io inserts exactly one call_io row' );
|
||||
$this->assertSame( 'what is BPC-157?', $io_rows[0]['prompt_text'], 'write_io stores prompt_text' );
|
||||
$this->assertSame( 'BPC-157 is a peptide...', $io_rows[0]['response_text'], 'write_io stores response_text' );
|
||||
}
|
||||
|
||||
// ── Test 3 (P0 SECURITY — direct writer): 401 does not store auth ────
|
||||
|
||||
public function test_p0_direct_writer_401_no_auth_material_stored(): void {
|
||||
$writer = new PRV_Capture_Writer();
|
||||
$writer->write_meta(
|
||||
array(
|
||||
'visibility_row' => null,
|
||||
'run_id' => 'error-run-1',
|
||||
'peptide_slug' => 'tb-500',
|
||||
'model' => 'openai/gpt-4o',
|
||||
'intent_label' => 'what is {peptide}',
|
||||
'tokens_in' => null,
|
||||
'tokens_out' => null,
|
||||
'cost_usd' => 0.0,
|
||||
'latency_ms' => 150,
|
||||
'cited' => null,
|
||||
'http_status' => 401,
|
||||
'config_version' => 3,
|
||||
)
|
||||
);
|
||||
$writer->write_io( 1, 'what is TB-500?', '' );
|
||||
|
||||
$err_meta = $GLOBALS['prv_test_state']['wpdb_call_meta_rows'][0];
|
||||
$err_io = $GLOBALS['prv_test_state']['wpdb_call_io_rows'][0];
|
||||
|
||||
$this->assertSame( 401, $err_meta['http_status'], 'Error record stores http_status 401' );
|
||||
|
||||
$all_stored = wp_json_encode( $err_meta ) . wp_json_encode( $err_io );
|
||||
$this->assertStringNotContainsString( 'Bearer', $all_stored, 'Stored data does not contain "Bearer"' );
|
||||
$this->assertStringNotContainsString( 'sk-or-', $all_stored, 'Stored data does not contain "sk-or-"' );
|
||||
$this->assertStringNotContainsString( 'Authorization', $all_stored, 'Stored data does not contain "Authorization"' );
|
||||
$this->assertNull( $err_meta['cited'], 'Error record has cited=null' );
|
||||
$this->assertSame( 'what is TB-500?', $err_io['prompt_text'], 'Error record stores rendered query as prompt_text' );
|
||||
}
|
||||
|
||||
// ── Test 4 (P0 SECURITY — executor path): sentinel must not leak ──────
|
||||
|
||||
public function test_p0_executor_path_sentinel_key_does_not_leak(): void {
|
||||
if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) ) {
|
||||
define( 'PRV_OPENROUTER_API_KEY', 'sk-or-TEST-LEAK-CANARY' );
|
||||
}
|
||||
|
||||
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
|
||||
$GLOBALS['prv_test_state']['options']['prv_peptides'] = array( array( 'slug' => 'bpc-157', 'label' => 'BPC-157' ) );
|
||||
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = array( 'what is {peptide}' );
|
||||
$GLOBALS['prv_test_state']['options']['prv_models_v2'] = array(
|
||||
array( 'slug' => 'openai/gpt-4o', 'enabled' => true ),
|
||||
);
|
||||
$GLOBALS['prv_test_state']['options']['prv_active_config_version'] = 3;
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '0.0';
|
||||
|
||||
// Queue a 401 response.
|
||||
$GLOBALS['prv_test_state']['remote_posts'][] = array(
|
||||
'response' => array( 'code' => 401 ),
|
||||
'body' => '{"error":{"message":"Invalid API key","code":401}}',
|
||||
);
|
||||
|
||||
$sentinel_capture = new PRV_Capture_Writer();
|
||||
$sentinel_ledger = new PRV_Cost_Ledger();
|
||||
$sentinel_runner = new PRV_Probe_Runner( $sentinel_ledger, $sentinel_capture );
|
||||
|
||||
try {
|
||||
$sentinel_counts = $sentinel_runner->run();
|
||||
} catch ( \Throwable $ex ) {
|
||||
$sentinel_counts = array( 'skipped_error' => -99 );
|
||||
}
|
||||
|
||||
$this->assertGreaterThanOrEqual( 1, $sentinel_counts['skipped_error'], 'Executor recorded a skipped_error for the 401 path' );
|
||||
|
||||
$all_meta = $GLOBALS['prv_test_state']['wpdb_call_meta_rows'];
|
||||
$all_io = $GLOBALS['prv_test_state']['wpdb_call_io_rows'];
|
||||
$all_json = wp_json_encode( $all_meta ) . wp_json_encode( $all_io );
|
||||
|
||||
$this->assertStringNotContainsString( 'sk-or-TEST-LEAK-CANARY', $all_json, 'Sentinel key does not appear in any stored row (executor path)' );
|
||||
$this->assertStringNotContainsString( 'Bearer', $all_json, 'Bearer does not appear in any stored row (executor path)' );
|
||||
$this->assertStringNotContainsString( 'Authorization', $all_json, 'Authorization does not appear in any stored row (executor path)' );
|
||||
$this->assertGreaterThanOrEqual( 1, count( $all_meta ), 'Executor path wrote at least one call_meta row on 401' );
|
||||
|
||||
if ( ! empty( $all_meta ) ) {
|
||||
$this->assertSame( 401, $all_meta[0]['http_status'], 'Executor 401 path stores http_status=401 in call_meta' );
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 5: update_meta_cost writes back the cost ─────────────────────
|
||||
|
||||
public function test_update_meta_cost_returns_true(): void {
|
||||
$writer = new PRV_Capture_Writer();
|
||||
$result = $writer->update_meta_cost( 5, 0.004567 );
|
||||
|
||||
$this->assertTrue( $result, 'update_meta_cost returns true' );
|
||||
}
|
||||
}
|
||||
101
tests/unit/CitationDetectorTest.php
Normal file
101
tests/unit/CitationDetectorTest.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Citation_Detector: domain extraction and cite detection.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers PRV_Citation_Detector
|
||||
*/
|
||||
class CitationDetectorTest extends TestCase {
|
||||
|
||||
private PRV_Citation_Detector $detector;
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
$this->detector = new PRV_Citation_Detector();
|
||||
}
|
||||
|
||||
public function test_parse_plain_url_strings(): void {
|
||||
$domains = $this->detector->parse_domains( [
|
||||
'https://peptiderepo.com/bpc-157/',
|
||||
'https://www.examine.com/supplements/bpc-157/',
|
||||
'https://pubmed.ncbi.nlm.nih.gov/12345',
|
||||
] );
|
||||
|
||||
$this->assertContains( 'peptiderepo.com', $domains, 'parse_domains: peptiderepo.com extracted from URL' );
|
||||
$this->assertContains( 'examine.com', $domains, 'parse_domains: www. stripped from examine.com' );
|
||||
$this->assertContains( 'pubmed.ncbi.nlm.nih.gov', $domains, 'parse_domains: full subdomain preserved when not www.' );
|
||||
$this->assertCount( 3, $domains, 'parse_domains: exactly 3 unique domains' );
|
||||
}
|
||||
|
||||
public function test_parse_object_style_citations_deduplicates(): void {
|
||||
$domains = $this->detector->parse_domains( [
|
||||
[ 'url' => 'https://peptiderepo.com/tb-500/' ],
|
||||
[ 'url' => 'https://examine.com/tb500' ],
|
||||
[ 'url' => 'https://peptiderepo.com/bpc-157/' ], // duplicate domain
|
||||
] );
|
||||
|
||||
$this->assertCount( 2, $domains, 'parse_domains: object-style citations, duplicates removed' );
|
||||
$this->assertContains( 'peptiderepo.com', $domains, 'parse_domains: object-style, peptiderepo.com present' );
|
||||
}
|
||||
|
||||
public function test_parse_empty_input_returns_empty_array(): void {
|
||||
$domains = $this->detector->parse_domains( [] );
|
||||
$this->assertSame( [], $domains, 'parse_domains: empty input returns empty array' );
|
||||
}
|
||||
|
||||
public function test_is_cited_returns_true_when_target_present(): void {
|
||||
$this->assertTrue( $this->detector->is_cited( ['peptiderepo.com', 'examine.com'] ) );
|
||||
}
|
||||
|
||||
public function test_is_cited_returns_false_when_target_absent(): void {
|
||||
$this->assertFalse( $this->detector->is_cited( ['examine.com', 'pubmed.ncbi.nlm.nih.gov'] ) );
|
||||
}
|
||||
|
||||
public function test_is_cited_returns_false_for_empty_list(): void {
|
||||
$this->assertFalse( $this->detector->is_cited( [] ) );
|
||||
}
|
||||
|
||||
public function test_get_our_position_returns_1based_index(): void {
|
||||
$domains = ['examine.com', 'peptiderepo.com', 'pubmed.ncbi.nlm.nih.gov'];
|
||||
$this->assertSame( 2, $this->detector->get_our_position( $domains ), 'position=2' );
|
||||
|
||||
$domains_first = ['peptiderepo.com', 'examine.com'];
|
||||
$this->assertSame( 1, $this->detector->get_our_position( $domains_first ), 'position=1 when first' );
|
||||
}
|
||||
|
||||
public function test_get_our_position_returns_null_when_not_present(): void {
|
||||
$domains_absent = ['examine.com', 'pubmed.ncbi.nlm.nih.gov'];
|
||||
$this->assertNull( $this->detector->get_our_position( $domains_absent ), 'null when not present' );
|
||||
}
|
||||
|
||||
public function test_parse_gracefully_skips_malformed_items(): void {
|
||||
$domains = $this->detector->parse_domains( [
|
||||
'not-a-url',
|
||||
42,
|
||||
null,
|
||||
['no_url_key' => 'foo'],
|
||||
['url' => ''],
|
||||
] );
|
||||
$this->assertSame( [], $domains, 'parse_domains: gracefully skips malformed items' );
|
||||
}
|
||||
|
||||
public function test_p2a_www_strip_does_not_corrupt_w_domains(): void {
|
||||
$domains = $this->detector->parse_domains( [
|
||||
'https://wikipedia.org/wiki/bpc-157',
|
||||
'https://www.examine.com/bpc-157/',
|
||||
'https://webmd.com/drug/bpc-157',
|
||||
] );
|
||||
|
||||
$this->assertContains( 'wikipedia.org', $domains, 'P2-A: wikipedia.org survives intact' );
|
||||
$this->assertContains( 'examine.com', $domains, 'P2-A: www.examine.com correctly stripped' );
|
||||
$this->assertContains( 'webmd.com', $domains, 'P2-A: webmd.com survives intact' );
|
||||
$this->assertCount( 3, $domains, 'P2-A: exactly 3 unique domains' );
|
||||
}
|
||||
}
|
||||
120
tests/unit/ConfigVersionTest.php
Normal file
120
tests/unit/ConfigVersionTest.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Config_Version: config hash, bump, version records.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers PRV_Config_Version
|
||||
*/
|
||||
class ConfigVersionTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
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', '' );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_maybe_seed_initial_version_creates_v1(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
PRV_Config_Version::maybe_seed_initial_version();
|
||||
|
||||
$versions = PRV_Config_Version::get_all_versions();
|
||||
$this->assertGreaterThanOrEqual( 1, count( $versions ), 'seed: at least 1 version record after initial seed' );
|
||||
$this->assertSame( 1, (int) $versions[0]['version'], 'seed: first version number is 1' );
|
||||
$this->assertNotEmpty( $versions[0]['hash'], 'seed: version has a hash' );
|
||||
$this->assertSame( 1, PRV_Config_Version::get_active_version(), 'seed: active version is 1' );
|
||||
}
|
||||
|
||||
public function test_maybe_seed_is_idempotent(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
PRV_Config_Version::maybe_seed_initial_version();
|
||||
PRV_Config_Version::maybe_seed_initial_version();
|
||||
|
||||
$this->assertCount( 1, PRV_Config_Version::get_all_versions(), 'seed idempotent: still 1 version after second seed call' );
|
||||
}
|
||||
|
||||
public function test_bump_returns_null_when_config_unchanged(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
PRV_Config_Version::maybe_seed_initial_version();
|
||||
|
||||
$bump = PRV_Config_Version::bump_version_if_changed();
|
||||
|
||||
$this->assertNull( $bump, 'bump: returns null when config unchanged' );
|
||||
$this->assertSame( 1, PRV_Config_Version::get_active_version(), 'bump: active version stays 1 when config unchanged' );
|
||||
}
|
||||
|
||||
public function test_bump_creates_new_version_when_config_changes(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
PRV_Config_Version::maybe_seed_initial_version();
|
||||
|
||||
PRV_Model_Registry::add( 'anthropic/claude-3-haiku', 'openrouter', true, 'New model' );
|
||||
$new_ver = PRV_Config_Version::bump_version_if_changed();
|
||||
|
||||
$this->assertNotNull( $new_ver, 'bump: returns new version number when config changed' );
|
||||
$this->assertSame( 2, (int) $new_ver, 'bump: new version is 2' );
|
||||
$this->assertSame( 2, PRV_Config_Version::get_active_version(), 'bump: active version updated to 2' );
|
||||
$this->assertCount( 2, PRV_Config_Version::get_all_versions(), 'bump: 2 version records exist' );
|
||||
}
|
||||
|
||||
public function test_would_change_detects_config_diff(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
PRV_Config_Version::maybe_seed_initial_version();
|
||||
|
||||
$original_hash = PRV_Config_Version::compute_hash();
|
||||
$this->assertFalse( PRV_Config_Version::would_change( $original_hash ), 'would_change: false when hash is current' );
|
||||
|
||||
PRV_Model_Registry::add( 'x/new-model', 'openrouter', true );
|
||||
$new_hash = PRV_Config_Version::compute_hash();
|
||||
$this->assertTrue( PRV_Config_Version::would_change( $new_hash ), 'would_change: true when config differs' );
|
||||
}
|
||||
|
||||
public function test_compute_hash_is_stable_for_same_config(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
$h1 = PRV_Config_Version::compute_hash();
|
||||
$h2 = PRV_Config_Version::compute_hash();
|
||||
|
||||
$this->assertSame( $h1, $h2, 'compute_hash: stable for same config' );
|
||||
}
|
||||
|
||||
public function test_runner_stamps_config_version(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
PRV_Config_Version::maybe_seed_initial_version();
|
||||
|
||||
$this->assertSame( 1, PRV_Config_Version::get_active_version(), 'version stamp: active version is 1 before run' );
|
||||
|
||||
$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();
|
||||
|
||||
$this->assertSame( 1, $result['probed'], 'version stamp: runner completes 1 probe' );
|
||||
$this->assertSame( 1, PRV_Config_Version::get_active_version(), 'version stamp: config version unchanged after run' );
|
||||
}
|
||||
}
|
||||
111
tests/unit/CostAuditTest.php
Normal file
111
tests/unit/CostAuditTest.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests: cost audit trail — rollup reconciliation, prune, and
|
||||
* probe-runner best-effort capture.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/** Mock capture writer that throws in write_io. */
|
||||
class PRV_Capture_Writer_Throwing extends PRV_Capture_Writer {
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function write_io( int $call_id, string $prompt_text, string $response_text ): bool {
|
||||
throw new \RuntimeException( 'Simulated capture_io failure' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers PRV_Cost_Rollup_Query
|
||||
* @covers PRV_Cost_Ledger
|
||||
* @covers PRV_Prune_Cron
|
||||
* @covers PRV_Table_Manager
|
||||
*/
|
||||
class CostAuditTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
}
|
||||
|
||||
public function test_get_mtd_summary_returns_positive_cost(): void {
|
||||
$GLOBALS['prv_test_state']['wpdb_row'] = array( 'total_cost' => '1.23456789', 'total_calls' => '5' );
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '1.23456789';
|
||||
|
||||
$rollup = new PRV_Cost_Rollup_Query();
|
||||
$summary = $rollup->get_mtd_summary();
|
||||
|
||||
$this->assertGreaterThan( 0, $summary['total_cost'], 'get_mtd_summary returns positive cost when rows present' );
|
||||
}
|
||||
|
||||
public function test_ledger_mtd_matches_mock(): void {
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '1.23456789';
|
||||
|
||||
$ledger = new PRV_Cost_Ledger();
|
||||
$mtd_via_ledger = $ledger->get_month_to_date_usd();
|
||||
|
||||
$this->assertSame( (float) '1.23456789', $mtd_via_ledger, 'PRV_Cost_Ledger MTD matches mock value' );
|
||||
}
|
||||
|
||||
public function test_project_month_end_returns_at_least_mtd(): void {
|
||||
$GLOBALS['prv_test_state']['wpdb_row'] = array( 'total_cost' => '1.23456789', 'total_calls' => '5' );
|
||||
|
||||
$rollup = new PRV_Cost_Rollup_Query();
|
||||
$projected = $rollup->project_month_end( 1.23 );
|
||||
|
||||
$this->assertGreaterThanOrEqual( 1.23, $projected, 'project_month_end returns at least MTD value' );
|
||||
$this->assertIsFloat( $projected, 'project_month_end returns a float' );
|
||||
}
|
||||
|
||||
public function test_prune_now_returns_int_and_does_not_drop_call_meta(): void {
|
||||
$deleted = PRV_Prune_Cron::prune_now();
|
||||
|
||||
$this->assertIsInt( $deleted, 'prune_now returns an integer' );
|
||||
$this->assertNotContains(
|
||||
'prv_call_meta',
|
||||
$GLOBALS['prv_test_state']['wpdb_dropped_tables'],
|
||||
'prune_now does not drop prv_call_meta'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_capture_io_exception_does_not_propagate_to_runner(): void {
|
||||
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
|
||||
$GLOBALS['prv_test_state']['options']['prv_peptides'] = array( array( 'slug' => 'bpc-157', 'label' => 'BPC-157' ) );
|
||||
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = array( 'what is {peptide}' );
|
||||
$GLOBALS['prv_test_state']['options']['prv_models_v2'] = array(
|
||||
array( 'slug' => 'perplexity/sonar', 'enabled' => true ),
|
||||
);
|
||||
$GLOBALS['prv_test_state']['options']['prv_active_config_version'] = 3;
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '0.0';
|
||||
|
||||
$throwing_capture = new PRV_Capture_Writer_Throwing();
|
||||
$ledger = new PRV_Cost_Ledger();
|
||||
$runner = new PRV_Probe_Runner( $ledger, $throwing_capture );
|
||||
$exception_escaped = false;
|
||||
$counts = null;
|
||||
|
||||
try {
|
||||
$counts = $runner->run();
|
||||
} catch ( \Throwable $e ) {
|
||||
$exception_escaped = true;
|
||||
}
|
||||
|
||||
$this->assertFalse( $exception_escaped, 'forced capture_io exception does not propagate to probe runner caller' );
|
||||
$this->assertIsArray( $counts, 'runner returns counts array even when capture_io throws' );
|
||||
}
|
||||
|
||||
public function test_drop_table_methods_work_correctly(): void {
|
||||
PRV_Table_Manager::drop_table();
|
||||
PRV_Call_Io_Table::drop_table();
|
||||
PRV_Call_Meta_Table::drop_table();
|
||||
|
||||
$dropped = $GLOBALS['prv_test_state']['wpdb_dropped_tables'];
|
||||
$this->assertContains( 'prv_call_io', $dropped, 'drop_table() drops prv_call_io' );
|
||||
$this->assertContains( 'prv_call_meta', $dropped, 'drop_table() drops prv_call_meta' );
|
||||
$this->assertContains( 'prv_ai_visibility', $dropped, 'drop_table() drops prv_ai_visibility (existing table)' );
|
||||
}
|
||||
}
|
||||
82
tests/unit/CostLedgerTest.php
Normal file
82
tests/unit/CostLedgerTest.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Cost_Ledger: MTD cost retrieval, budget pre-check,
|
||||
* and the budget-cap abort behaviour.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers PRV_Cost_Ledger
|
||||
*/
|
||||
class CostLedgerTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
}
|
||||
|
||||
public function test_can_afford_below_cap(): void {
|
||||
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '2.50';
|
||||
|
||||
$ledger = new PRV_Cost_Ledger();
|
||||
$this->assertTrue( $ledger->can_afford( 1.0 ), 'can_afford: 2.50 spent + 1.00 = 3.50 < 5.00 → true' );
|
||||
}
|
||||
|
||||
public function test_can_afford_at_cap_returns_false(): void {
|
||||
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '5.00';
|
||||
|
||||
$ledger = new PRV_Cost_Ledger();
|
||||
$this->assertFalse( $ledger->can_afford( 0.01 ), 'can_afford: 5.00 spent + 0.01 > 5.00 → false' );
|
||||
}
|
||||
|
||||
public function test_can_afford_exactly_at_cap_is_allowed(): void {
|
||||
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '4.99';
|
||||
|
||||
$ledger = new PRV_Cost_Ledger();
|
||||
$this->assertTrue( $ledger->can_afford( 0.01 ), 'can_afford: 4.99 + 0.01 = 5.00 exactly → true' );
|
||||
}
|
||||
|
||||
public function test_get_remaining_budget_usd_correct_value(): void {
|
||||
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '3.25';
|
||||
|
||||
$ledger = new PRV_Cost_Ledger();
|
||||
$this->assertSame( 1.75, round( $ledger->get_remaining_budget_usd(), 2 ), 'get_remaining_budget_usd: 5.00 - 3.25 = 1.75' );
|
||||
}
|
||||
|
||||
public function test_get_remaining_budget_never_negative(): void {
|
||||
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '10.00';
|
||||
|
||||
$ledger = new PRV_Cost_Ledger();
|
||||
$this->assertSame( 0.0, $ledger->get_remaining_budget_usd(), 'get_remaining_budget_usd: clamps to 0.0 when over cap' );
|
||||
}
|
||||
|
||||
public function test_get_month_to_date_usd_null_returns_zero(): void {
|
||||
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = null;
|
||||
|
||||
$ledger = new PRV_Cost_Ledger();
|
||||
$this->assertSame( 0.0, $ledger->get_month_to_date_usd(), 'get_month_to_date_usd: null DB result → 0.0' );
|
||||
}
|
||||
|
||||
public function test_update_row_cost_returns_true(): void {
|
||||
$ledger = new PRV_Cost_Ledger();
|
||||
$this->assertTrue( $ledger->update_row_cost( 42, 0.00123456 ), 'update_row_cost: returns true on success' );
|
||||
}
|
||||
|
||||
public function test_budget_abort_can_afford_false_at_cap(): void {
|
||||
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 0.001;
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '0.001';
|
||||
|
||||
$ledger = new PRV_Cost_Ledger();
|
||||
$this->assertFalse( $ledger->can_afford( 0.000001 ), 'budget abort: can_afford returns false at cap → runner would stop' );
|
||||
}
|
||||
}
|
||||
101
tests/unit/CronGuardTest.php
Normal file
101
tests/unit/CronGuardTest.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Cron_Guard: self-healing cron scheduling.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers PRV_Cron_Guard
|
||||
*/
|
||||
class CronGuardTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
}
|
||||
|
||||
public function test_ensure_scheduled_schedules_weekly_hook(): void {
|
||||
$this->assertFalse( wp_next_scheduled( PRV_CRON_HOOK ), 'guard/weekly: not scheduled before call' );
|
||||
|
||||
$guard = new PRV_Cron_Guard();
|
||||
$guard->ensure_scheduled();
|
||||
|
||||
$this->assertNotFalse( wp_next_scheduled( PRV_CRON_HOOK ), 'guard/weekly: prv_weekly_probe scheduled after ensure_scheduled()' );
|
||||
}
|
||||
|
||||
public function test_ensure_scheduled_schedules_prune_hook(): void {
|
||||
$this->assertFalse( wp_next_scheduled( PRV_Prune_Cron::PRUNE_HOOK ), 'guard/prune: not scheduled before call' );
|
||||
|
||||
$guard = new PRV_Cron_Guard();
|
||||
$guard->ensure_scheduled();
|
||||
|
||||
$this->assertNotFalse( wp_next_scheduled( PRV_Prune_Cron::PRUNE_HOOK ), 'guard/prune: prv_daily_prune scheduled after ensure_scheduled()' );
|
||||
}
|
||||
|
||||
public function test_ensure_scheduled_is_idempotent(): void {
|
||||
$guard = new PRV_Cron_Guard();
|
||||
$guard->ensure_scheduled();
|
||||
|
||||
$ts_weekly_1 = wp_next_scheduled( PRV_CRON_HOOK );
|
||||
$ts_prune_1 = wp_next_scheduled( PRV_Prune_Cron::PRUNE_HOOK );
|
||||
|
||||
$guard->ensure_scheduled(); // Second call.
|
||||
|
||||
$ts_weekly_2 = wp_next_scheduled( PRV_CRON_HOOK );
|
||||
$ts_prune_2 = wp_next_scheduled( PRV_Prune_Cron::PRUNE_HOOK );
|
||||
|
||||
$this->assertSame( $ts_weekly_1, $ts_weekly_2, 'guard/idempotent: weekly event timestamp unchanged after second call' );
|
||||
$this->assertSame( $ts_prune_1, $ts_prune_2, 'guard/idempotent: prune event timestamp unchanged after second call' );
|
||||
|
||||
$cron_events = $GLOBALS['prv_test_state']['cron_events'];
|
||||
$this->assertArrayHasKey( PRV_CRON_HOOK, $cron_events, 'guard/idempotent: exactly one weekly event entry' );
|
||||
$this->assertArrayHasKey( PRV_Prune_Cron::PRUNE_HOOK, $cron_events, 'guard/idempotent: exactly one prune event entry' );
|
||||
$this->assertCount( 2, $cron_events, 'guard/idempotent: only two cron event entries total' );
|
||||
}
|
||||
|
||||
public function test_guard_uses_same_recurrence_as_canonical_schedulers(): void {
|
||||
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
|
||||
PRV_Cron::schedule();
|
||||
$canonical_weekly = $GLOBALS['prv_test_state']['cron_events'][ PRV_CRON_HOOK ]['schedule'] ?? null;
|
||||
|
||||
prv_test_reset();
|
||||
PRV_Prune_Cron::schedule();
|
||||
$canonical_prune = $GLOBALS['prv_test_state']['cron_events'][ PRV_Prune_Cron::PRUNE_HOOK ]['schedule'] ?? null;
|
||||
|
||||
prv_test_reset();
|
||||
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
|
||||
$guard2 = new PRV_Cron_Guard();
|
||||
$guard2->ensure_scheduled();
|
||||
|
||||
$guard_weekly = $GLOBALS['prv_test_state']['cron_events'][ PRV_CRON_HOOK ]['schedule'] ?? null;
|
||||
$guard_prune = $GLOBALS['prv_test_state']['cron_events'][ PRV_Prune_Cron::PRUNE_HOOK ]['schedule'] ?? null;
|
||||
|
||||
$this->assertSame( $canonical_weekly, $guard_weekly, 'guard/recurrence: weekly matches canonical PRV_Cron::schedule()' );
|
||||
$this->assertSame( $canonical_prune, $guard_prune, 'guard/recurrence: prune matches canonical PRV_Prune_Cron::schedule()' );
|
||||
}
|
||||
|
||||
public function test_ensure_scheduled_noop_when_both_already_scheduled(): void {
|
||||
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
|
||||
PRV_Cron::schedule();
|
||||
PRV_Prune_Cron::schedule();
|
||||
$before = $GLOBALS['prv_test_state']['cron_events'];
|
||||
|
||||
$guard3 = new PRV_Cron_Guard();
|
||||
$guard3->ensure_scheduled();
|
||||
|
||||
$after = $GLOBALS['prv_test_state']['cron_events'];
|
||||
$this->assertSame( $before, $after, 'guard/noop: cron_events unchanged when all events already scheduled' );
|
||||
}
|
||||
|
||||
public function test_register_hooks_adds_init_action(): void {
|
||||
$guard4 = new PRV_Cron_Guard();
|
||||
$guard4->register_hooks();
|
||||
|
||||
$hooks = array_column( $GLOBALS['prv_test_state']['actions'], 'hook' );
|
||||
$this->assertContains( 'init', $hooks, 'guard/register_hooks: init action registered' );
|
||||
}
|
||||
}
|
||||
92
tests/unit/CronRescheduleTest.php
Normal file
92
tests/unit/CronRescheduleTest.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Cron cadence reschedule on config change.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers PRV_Cron
|
||||
* @covers PRV_Config
|
||||
*/
|
||||
class CronRescheduleTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
}
|
||||
|
||||
public function test_schedule_weekly_with_configured_cadence(): void {
|
||||
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
|
||||
PRV_Cron::schedule_weekly();
|
||||
|
||||
$this->assertTrue( PRV_Cron::is_scheduled(), 'schedule: event scheduled' );
|
||||
$next = PRV_Cron::next_run_timestamp();
|
||||
$this->assertNotFalse( $next, 'schedule: next_run_timestamp is not false' );
|
||||
$this->assertGreaterThan( 0, (int) $next, 'schedule: next_run_timestamp is positive' );
|
||||
}
|
||||
|
||||
public function test_reschedule_clears_old_and_adds_new(): void {
|
||||
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
|
||||
PRV_Cron::schedule();
|
||||
$this->assertTrue( PRV_Cron::is_scheduled(), 'reschedule pre: event is scheduled' );
|
||||
|
||||
PRV_Cron::reschedule( 'daily' );
|
||||
$this->assertTrue( PRV_Cron::is_scheduled(), 'reschedule post: event is still scheduled after reschedule' );
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->assertTrue( $found_daily, 'reschedule: new cron has daily recurrence' );
|
||||
}
|
||||
|
||||
public function test_clear_schedule_removes_event(): void {
|
||||
PRV_Cron::schedule();
|
||||
$this->assertTrue( PRV_Cron::is_scheduled(), 'clear pre: event is scheduled' );
|
||||
|
||||
PRV_Cron::clear_schedule();
|
||||
$this->assertFalse( PRV_Cron::is_scheduled(), 'clear: event removed after clear_schedule' );
|
||||
}
|
||||
|
||||
public function test_schedule_is_idempotent(): void {
|
||||
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
|
||||
PRV_Cron::schedule();
|
||||
$ts1 = PRV_Cron::next_run_timestamp();
|
||||
PRV_Cron::schedule();
|
||||
$ts2 = PRV_Cron::next_run_timestamp();
|
||||
|
||||
$this->assertSame( $ts1, $ts2, 'schedule idempotent: same timestamp after two schedule() calls at same cadence' );
|
||||
}
|
||||
|
||||
public function test_get_cadence_defaults_to_weekly(): void {
|
||||
$cadence = PRV_Config::get_cadence();
|
||||
$this->assertSame( 'weekly', $cadence, 'get_cadence: defaults to weekly' );
|
||||
}
|
||||
|
||||
public function test_invalid_cadence_falls_back_to_weekly(): void {
|
||||
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'invalid_cadence';
|
||||
$cadence = PRV_Config::get_cadence();
|
||||
$this->assertSame( 'weekly', $cadence, 'get_cadence: invalid value falls back to weekly' );
|
||||
}
|
||||
|
||||
public function test_cron_tick_skipped_when_run_lock_held(): void {
|
||||
PRV_Run_Lock::acquire();
|
||||
$cron = new PRV_Cron();
|
||||
$cron->handle_cron_tick();
|
||||
|
||||
$this->assertTrue( PRV_Run_Lock::is_locked(), 'cron tick: lock still held (run was skipped)' );
|
||||
PRV_Run_Lock::release();
|
||||
$this->assertFalse( PRV_Run_Lock::is_locked(), 'cron tick cleanup: lock released' );
|
||||
}
|
||||
}
|
||||
68
tests/unit/CronTest.php
Normal file
68
tests/unit/CronTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Cron: scheduling, clearing, and registration.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers PRV_Cron
|
||||
* @covers PRV_Activator
|
||||
* @covers PRV_Deactivator
|
||||
*/
|
||||
class CronTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
}
|
||||
|
||||
public function test_schedule_weekly_adds_event(): void {
|
||||
$this->assertFalse( PRV_Cron::is_scheduled() );
|
||||
PRV_Cron::schedule_weekly();
|
||||
$this->assertTrue( PRV_Cron::is_scheduled() );
|
||||
}
|
||||
|
||||
public function test_schedule_weekly_is_idempotent(): void {
|
||||
PRV_Cron::schedule_weekly();
|
||||
PRV_Cron::schedule_weekly();
|
||||
$events = $GLOBALS['prv_test_state']['cron_events'];
|
||||
$this->assertCount( 1, $events );
|
||||
}
|
||||
|
||||
public function test_clear_schedule_removes_event(): void {
|
||||
PRV_Cron::schedule_weekly();
|
||||
PRV_Cron::clear_schedule();
|
||||
$this->assertFalse( PRV_Cron::is_scheduled() );
|
||||
}
|
||||
|
||||
public function test_clear_schedule_safe_when_nothing_scheduled(): void {
|
||||
PRV_Cron::clear_schedule();
|
||||
$this->assertFalse( PRV_Cron::is_scheduled() );
|
||||
}
|
||||
|
||||
public function test_register_hooks_registers_cron_hook_action(): void {
|
||||
$cron = new PRV_Cron();
|
||||
$cron->register_hooks();
|
||||
$hooks = array_column( $GLOBALS['prv_test_state']['actions'], 'hook' );
|
||||
$this->assertContains( PRV_CRON_HOOK, $hooks );
|
||||
}
|
||||
|
||||
public function test_prv_cron_hook_constant_value(): void {
|
||||
$this->assertSame( 'prv_weekly_probe', PRV_CRON_HOOK );
|
||||
}
|
||||
|
||||
public function test_activator_schedules_cron(): void {
|
||||
PRV_Activator::activate();
|
||||
$this->assertTrue( PRV_Cron::is_scheduled() );
|
||||
}
|
||||
|
||||
public function test_deactivator_clears_cron(): void {
|
||||
PRV_Activator::activate();
|
||||
PRV_Deactivator::deactivate();
|
||||
$this->assertFalse( PRV_Cron::is_scheduled() );
|
||||
}
|
||||
}
|
||||
141
tests/unit/DashboardPanelTest.php
Normal file
141
tests/unit/DashboardPanelTest.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Ai_Visibility_Panel and PRV_Dashboard_Renderer data-shaping.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DashboardPanelTest extends TestCase {
|
||||
|
||||
private PRV_Ai_Visibility_Panel $panel;
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
$this->panel = new PRV_Ai_Visibility_Panel();
|
||||
}
|
||||
|
||||
private function callPrivate( object $obj, string $method, array $args = [] ): mixed {
|
||||
$ref = new ReflectionMethod( $obj, $method );
|
||||
$ref->setAccessible( true );
|
||||
return $ref->invokeArgs( $obj, $args );
|
||||
}
|
||||
|
||||
public function test_health_pill_empty_map_is_neutral(): void {
|
||||
$result = $this->callPrivate( $this->panel, 'derive_health_pill_state', [ [] ] );
|
||||
$this->assertSame( 'neutral', $result['state'] );
|
||||
}
|
||||
|
||||
public function test_health_pill_all_healthy_is_ok(): void {
|
||||
$result = $this->callPrivate( $this->panel, 'derive_health_pill_state', [
|
||||
[ 'a' => [ 'health_status' => 'healthy' ], 'b' => [ 'health_status' => 'healthy' ] ],
|
||||
] );
|
||||
$this->assertSame( 'ok', $result['state'] );
|
||||
$this->assertStringContainsString( 'healthy', $result['label'] );
|
||||
}
|
||||
|
||||
public function test_health_pill_one_retired_is_warn(): void {
|
||||
$result = $this->callPrivate( $this->panel, 'derive_health_pill_state', [
|
||||
[ 'a' => [ 'health_status' => 'healthy' ], 'b' => [ 'health_status' => 'retired' ] ],
|
||||
] );
|
||||
$this->assertSame( 'warn', $result['state'] );
|
||||
$this->assertStringContainsString( '1', $result['label'] );
|
||||
}
|
||||
|
||||
public function test_health_pill_disabled_only_is_neutral(): void {
|
||||
$result = $this->callPrivate( $this->panel, 'derive_health_pill_state', [
|
||||
[ 'a' => [ 'health_status' => 'disabled' ] ],
|
||||
] );
|
||||
$this->assertSame( 'neutral', $result['state'] );
|
||||
}
|
||||
|
||||
public function test_build_delta_html_positive_gets_up_class(): void {
|
||||
$renderer = new PRV_Dashboard_Renderer();
|
||||
$out = $renderer->build_delta_html( 0.10, 0.08 );
|
||||
$this->assertStringContainsString( 'prv-delta--up', $out );
|
||||
$this->assertStringContainsString( '+', $out );
|
||||
$this->assertStringContainsString( '2.00', $out );
|
||||
}
|
||||
|
||||
public function test_build_delta_html_negative_gets_down_class(): void {
|
||||
$renderer = new PRV_Dashboard_Renderer();
|
||||
$this->assertStringContainsString( 'prv-delta--down', $renderer->build_delta_html( 0.05, 0.08 ) );
|
||||
}
|
||||
|
||||
public function test_build_delta_html_zero_gets_flat_class(): void {
|
||||
$renderer = new PRV_Dashboard_Renderer();
|
||||
$this->assertStringContainsString( 'prv-delta--flat', $renderer->build_delta_html( 0.08, 0.08 ) );
|
||||
}
|
||||
|
||||
public function test_build_delta_html_null_previous_returns_empty(): void {
|
||||
$renderer = new PRV_Dashboard_Renderer();
|
||||
$this->assertSame( '', $renderer->build_delta_html( 0.08, null ) );
|
||||
}
|
||||
|
||||
public function test_collector_trendline_includes_config_version(): void {
|
||||
$GLOBALS['prv_test_state']['wpdb_results'] = [
|
||||
[ 'run_id' => 'run-001', 'captured_at' => '2026-06-01 10:00:00', 'cited_count' => 2, 'total_count' => 10, 'position_sum' => 0.5, 'config_version' => 1 ],
|
||||
[ 'run_id' => 'run-002', 'captured_at' => '2026-06-08 10:00:00', 'cited_count' => 3, 'total_count' => 10, 'position_sum' => 0.6, 'config_version' => 2 ],
|
||||
];
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '2026-06-08 10:00:00';
|
||||
|
||||
$data = ( new PRV_Ai_Visibility_Collector() )->collect();
|
||||
|
||||
$this->assertArrayHasKey( 'trendline', $data );
|
||||
$this->assertCount( 2, $data['trendline'] );
|
||||
$this->assertSame( 1, $data['trendline'][0]['config_version'] );
|
||||
$this->assertSame( 2, $data['trendline'][1]['config_version'] );
|
||||
}
|
||||
|
||||
public function test_collector_payload_has_new_keys(): void {
|
||||
$GLOBALS['prv_test_state']['wpdb_results'] = [];
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = null;
|
||||
|
||||
$data = ( new PRV_Ai_Visibility_Collector() )->collect();
|
||||
$this->assertArrayHasKey( 'last_run_counts', $data );
|
||||
$this->assertIsArray( $data['last_run_counts'] );
|
||||
$this->assertArrayHasKey( 'config_versions', $data );
|
||||
$this->assertIsArray( $data['config_versions'] );
|
||||
}
|
||||
|
||||
public function test_null_config_version_preserved(): void {
|
||||
$GLOBALS['prv_test_state']['wpdb_results'] = [
|
||||
[ 'run_id' => 'run-001', 'captured_at' => '2026-06-01 10:00:00', 'cited_count' => 1, 'total_count' => 5, 'position_sum' => 0.25, 'config_version' => null ],
|
||||
];
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '2026-06-01 10:00:00';
|
||||
|
||||
$data = ( new PRV_Ai_Visibility_Collector() )->collect();
|
||||
$this->assertCount( 1, $data['trendline'] );
|
||||
$this->assertNull( $data['trendline'][0]['config_version'] );
|
||||
}
|
||||
|
||||
public function test_single_version_trendline_produces_no_cfg_note(): void {
|
||||
$renderer = new PRV_Dashboard_Renderer();
|
||||
ob_start();
|
||||
$renderer->render_trendline( [
|
||||
[ 'run_id' => 'r1', 'captured_at' => '2026-06-01 10:00:00', 'score' => 0.0, 'config_version' => 1 ],
|
||||
[ 'run_id' => 'r2', 'captured_at' => '2026-06-08 10:00:00', 'score' => 0.0, 'config_version' => 1 ],
|
||||
] );
|
||||
$out = (string) ob_get_clean();
|
||||
$this->assertStringNotContainsString( 'prv-cfg-note', $out );
|
||||
$this->assertStringContainsString( 'prv-trendline-chart', $out );
|
||||
}
|
||||
|
||||
public function test_two_version_trendline_produces_cfg_note(): void {
|
||||
$renderer = new PRV_Dashboard_Renderer();
|
||||
ob_start();
|
||||
$renderer->render_trendline( [
|
||||
[ 'run_id' => 'r1', 'captured_at' => '2026-06-01 10:00:00', 'score' => 0.0, 'config_version' => 1 ],
|
||||
[ 'run_id' => 'r2', 'captured_at' => '2026-06-08 10:00:00', 'score' => 0.0, 'config_version' => 2 ],
|
||||
] );
|
||||
$out = (string) ob_get_clean();
|
||||
$this->assertStringContainsString( 'prv-cfg-note', $out );
|
||||
$this->assertStringContainsString( 'configMarker', $out );
|
||||
$this->assertStringContainsString( 'prv-leg-cfg', $out );
|
||||
$this->assertStringContainsString( 'prv-noscript', $out );
|
||||
$this->assertStringContainsString( 'prv-chart-fallback', $out );
|
||||
}
|
||||
}
|
||||
162
tests/unit/KeyStoreTest.php
Normal file
162
tests/unit/KeyStoreTest.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Key_Store and PRV_Crypto_Helper.
|
||||
*
|
||||
* Covers: encrypt/decrypt round-trip, resolver precedence,
|
||||
* write-only render, and Remove (clear_key).
|
||||
*
|
||||
* Security assertions (P0): plaintext never in stored option,
|
||||
* password input renders with empty value, wrong-key decrypts to empty.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers PRV_Key_Store
|
||||
* @covers PRV_Crypto_Helper
|
||||
*/
|
||||
class KeyStoreTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
}
|
||||
|
||||
// ── Group 1: PRV_Crypto_Helper encrypt/decrypt round-trip ────────────
|
||||
|
||||
public function test_derive_encryption_key_returns_32_bytes(): void {
|
||||
$key = PRV_Key_Store::derive_encryption_key();
|
||||
$this->assertSame( 32, strlen( $key ), 'derive_encryption_key() returns 32-byte key' );
|
||||
}
|
||||
|
||||
public function test_encrypt_returns_non_empty_and_opaque(): void {
|
||||
$plaintext = 'sk-or-test-key-value-abc123';
|
||||
$enc_key = PRV_Key_Store::derive_encryption_key();
|
||||
$encrypted = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
|
||||
|
||||
$this->assertNotEmpty( $encrypted, 'encrypt() returns non-empty string' );
|
||||
$this->assertNotSame( $plaintext, $encrypted, 'encrypt() does not return plaintext' );
|
||||
$this->assertStringNotContainsString( $plaintext, $encrypted, 'Plaintext not visible in ciphertext' );
|
||||
}
|
||||
|
||||
public function test_decrypt_recovers_plaintext(): void {
|
||||
$plaintext = 'sk-or-test-key-value-abc123';
|
||||
$enc_key = PRV_Key_Store::derive_encryption_key();
|
||||
$encrypted = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
|
||||
$decrypted = PRV_Crypto_Helper::decrypt( $encrypted, $enc_key );
|
||||
|
||||
$this->assertSame( $plaintext, $decrypted, 'decrypt(encrypt(key)) === original key' );
|
||||
}
|
||||
|
||||
public function test_each_encryption_produces_unique_ciphertext(): void {
|
||||
$plaintext = 'sk-or-test-key-value-abc123';
|
||||
$enc_key = PRV_Key_Store::derive_encryption_key();
|
||||
$ct1 = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
|
||||
$ct2 = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
|
||||
|
||||
$this->assertNotSame( $ct1, $ct2, 'Two encryptions differ (random nonce)' );
|
||||
$this->assertSame( $plaintext, PRV_Crypto_Helper::decrypt( $ct2, $enc_key ), 'Second ciphertext also decrypts correctly' );
|
||||
}
|
||||
|
||||
public function test_decrypt_rejects_malformed_inputs(): void {
|
||||
$enc_key = PRV_Key_Store::derive_encryption_key();
|
||||
|
||||
$this->assertSame( '', PRV_Crypto_Helper::decrypt( '', $enc_key ), 'decrypt("") returns empty' );
|
||||
$this->assertSame( '', PRV_Crypto_Helper::decrypt( 'garbage', $enc_key ), 'decrypt(garbage) returns empty' );
|
||||
$this->assertSame( '', PRV_Crypto_Helper::decrypt( 'aabb:', $enc_key ), 'decrypt(empty cipher) returns empty' );
|
||||
}
|
||||
|
||||
public function test_decrypt_wrong_key_returns_empty(): void {
|
||||
$plaintext = 'sk-or-test-key-value-abc123';
|
||||
$enc_key = PRV_Key_Store::derive_encryption_key();
|
||||
$encrypted = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
|
||||
$wrong_key = hash( 'sha256', 'wrong-salt', true );
|
||||
|
||||
$this->assertSame( '', PRV_Crypto_Helper::decrypt( $encrypted, $wrong_key ), 'decrypt with wrong key returns empty' );
|
||||
}
|
||||
|
||||
// ── Group 2: store_key / clear_key / get_source ──────────────────────
|
||||
|
||||
public function test_get_source_none_when_no_key(): void {
|
||||
$this->assertSame( PRV_Key_Store::SOURCE_NONE, PRV_Key_Store::get_source() );
|
||||
}
|
||||
|
||||
public function test_store_key_writes_encrypted_option(): void {
|
||||
$plaintext = 'sk-or-test-key-value-abc123';
|
||||
$stored = PRV_Key_Store::store_key( $plaintext );
|
||||
|
||||
$this->assertTrue( $stored, 'store_key() returns true on success' );
|
||||
|
||||
$option_val = get_option( PRV_Key_Store::OPTION_ENC, '' );
|
||||
$this->assertNotEmpty( $option_val, 'Option is set after store_key()' );
|
||||
$this->assertNotSame( $plaintext, $option_val, 'Option value is NOT the plaintext key (encrypted at rest)' );
|
||||
$this->assertStringNotContainsString( $plaintext, (string) $option_val, 'Plaintext not in stored option value' );
|
||||
$this->assertStringNotContainsString( 'sk-or-', (string) $option_val, 'Key prefix not in stored option' );
|
||||
$this->assertSame( PRV_Key_Store::SOURCE_OPTION, PRV_Key_Store::get_source() );
|
||||
}
|
||||
|
||||
public function test_clear_key_removes_option(): void {
|
||||
PRV_Key_Store::store_key( 'sk-or-test-key-value-abc123' );
|
||||
PRV_Key_Store::clear_key();
|
||||
|
||||
$this->assertSame( '', get_option( PRV_Key_Store::OPTION_ENC, '' ) );
|
||||
$this->assertSame( PRV_Key_Store::SOURCE_NONE, PRV_Key_Store::get_source() );
|
||||
}
|
||||
|
||||
public function test_store_empty_key_is_noop(): void {
|
||||
$result = PRV_Key_Store::store_key( '' );
|
||||
|
||||
$this->assertFalse( $result, 'store_key("") returns false' );
|
||||
$this->assertSame( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option unchanged after store_key("")' );
|
||||
}
|
||||
|
||||
// ── Group 3: Resolver precedence — constant > option > none ──────────
|
||||
|
||||
public function test_get_key_returns_empty_with_no_source(): void {
|
||||
$this->assertSame( '', PRV_Key_Store::get_key() );
|
||||
}
|
||||
|
||||
public function test_get_key_returns_decrypted_from_option(): void {
|
||||
$plaintext = 'sk-or-test-key-value-abc123';
|
||||
PRV_Key_Store::store_key( $plaintext );
|
||||
|
||||
$this->assertSame( $plaintext, PRV_Key_Store::get_key() );
|
||||
}
|
||||
|
||||
// ── Group 4: Write-only — key value never in rendered HTML output ─────
|
||||
|
||||
public function test_rendered_html_never_contains_key(): void {
|
||||
$plaintext = 'sk-or-test-key-value-abc123';
|
||||
PRV_Key_Store::store_key( $plaintext );
|
||||
|
||||
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-settings-page.php';
|
||||
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-key-manager-renderer.php';
|
||||
|
||||
ob_start();
|
||||
( new PRV_Key_Manager_Renderer() )->render();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
$this->assertNotEmpty( $html, 'Key manager card renders non-empty HTML' );
|
||||
$this->assertStringNotContainsString( $plaintext, $html, 'Plaintext key absent from rendered HTML' );
|
||||
$this->assertStringNotContainsString( 'sk-or-test', $html, 'Key value prefix absent from rendered HTML' );
|
||||
$this->assertStringContainsString( 'type="password"', $html, 'Password input is present in output' );
|
||||
$this->assertStringContainsString( 'value=""', $html, 'Password input has empty value attribute' );
|
||||
$this->assertStringNotContainsString( 'value="sk-', $html, 'No key value in any value= attribute' );
|
||||
}
|
||||
|
||||
// ── Group 5: Remove clears the encrypted option ───────────────────────
|
||||
|
||||
public function test_clear_key_empties_option(): void {
|
||||
$plaintext = 'sk-or-test-key-value-abc123';
|
||||
PRV_Key_Store::store_key( $plaintext );
|
||||
|
||||
$this->assertNotEmpty( get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Key present before remove' );
|
||||
|
||||
PRV_Key_Store::clear_key();
|
||||
|
||||
$this->assertSame( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option empty after clear_key()' );
|
||||
}
|
||||
}
|
||||
146
tests/unit/ModelRegistryTest.php
Normal file
146
tests/unit/ModelRegistryTest.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Model_Registry: v1->v2 migration, CRUD, run-health.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers PRV_Model_Registry
|
||||
*/
|
||||
class ModelRegistryTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
}
|
||||
|
||||
public function test_migration_v1_to_v2_preserves_live_models(): void {
|
||||
$GLOBALS['prv_test_state']['options']['prv_models'] = [
|
||||
'perplexity/sonar',
|
||||
'openai/gpt-4o-search-preview',
|
||||
'google/gemini-2.0-flash-001',
|
||||
];
|
||||
|
||||
$migrated = PRV_Model_Registry::run_migration_v2();
|
||||
$this->assertTrue( $migrated );
|
||||
|
||||
$models = PRV_Model_Registry::get_all();
|
||||
$this->assertCount( 3, $models );
|
||||
|
||||
$slugs = array_column( $models, 'slug' );
|
||||
$this->assertContains( 'perplexity/sonar', $slugs );
|
||||
$this->assertContains( 'openai/gpt-4o-search-preview', $slugs );
|
||||
$this->assertContains( 'google/gemini-2.0-flash-001', $slugs );
|
||||
|
||||
foreach ( $models as $m ) {
|
||||
$this->assertArrayHasKey( 'id', $m );
|
||||
$this->assertArrayHasKey( 'provider', $m );
|
||||
$this->assertArrayHasKey( 'health_status', $m );
|
||||
$this->assertTrue( $m['enabled'] );
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
PRV_Model_Registry::SCHEMA_VERSION,
|
||||
(int) $GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ]
|
||||
);
|
||||
}
|
||||
|
||||
public function test_migration_is_idempotent(): void {
|
||||
$GLOBALS['prv_test_state']['options']['prv_models'] = [ 'perplexity/sonar' ];
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
$before_count = count( PRV_Model_Registry::get_all() );
|
||||
$second_run = PRV_Model_Registry::run_migration_v2();
|
||||
|
||||
$this->assertFalse( $second_run );
|
||||
$this->assertSame( $before_count, count( PRV_Model_Registry::get_all() ) );
|
||||
}
|
||||
|
||||
public function test_migration_seeds_defaults_when_no_models(): void {
|
||||
$migrated = PRV_Model_Registry::run_migration_v2();
|
||||
$this->assertTrue( $migrated );
|
||||
$this->assertGreaterThanOrEqual( 3, count( PRV_Model_Registry::get_all() ) );
|
||||
}
|
||||
|
||||
public function test_add_model(): void {
|
||||
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' );
|
||||
|
||||
$this->assertNotEmpty( $new_id );
|
||||
$this->assertStringStartsWith( 'mdl_', $new_id );
|
||||
$this->assertCount( $base_count + 1, PRV_Model_Registry::get_all() );
|
||||
|
||||
$found = PRV_Model_Registry::find_by_id( $new_id );
|
||||
$this->assertNotNull( $found );
|
||||
$this->assertSame( 'anthropic/claude-3-haiku', $found['slug'] );
|
||||
$this->assertSame( 'Test model', $found['note'] );
|
||||
}
|
||||
|
||||
public function test_update_model(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
$target = PRV_Model_Registry::get_all()[0];
|
||||
$result = PRV_Model_Registry::update( $target['id'], [ 'enabled' => false, 'note' => 'Disabled for test' ] );
|
||||
|
||||
$this->assertTrue( $result );
|
||||
$updated = PRV_Model_Registry::find_by_id( $target['id'] );
|
||||
$this->assertFalse( $updated['enabled'] );
|
||||
$this->assertSame( 'Disabled for test', $updated['note'] );
|
||||
}
|
||||
|
||||
public function test_remove_model(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
$all = PRV_Model_Registry::get_all();
|
||||
$id = $all[0]['id'];
|
||||
$count_before = count( $all );
|
||||
|
||||
$removed = PRV_Model_Registry::remove( $id );
|
||||
$this->assertTrue( $removed );
|
||||
$this->assertCount( $count_before - 1, PRV_Model_Registry::get_all() );
|
||||
$this->assertNull( PRV_Model_Registry::find_by_id( $id ) );
|
||||
}
|
||||
|
||||
public function test_remove_nonexistent_returns_false(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
$this->assertFalse( PRV_Model_Registry::remove( 'mdl_doesnotexist' ) );
|
||||
}
|
||||
|
||||
public function test_get_enabled_slugs_excludes_disabled(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
$all = PRV_Model_Registry::get_all();
|
||||
PRV_Model_Registry::update( $all[0]['id'], [ 'enabled' => false ] );
|
||||
$slugs = PRV_Model_Registry::get_enabled_slugs();
|
||||
|
||||
$this->assertNotContains( $all[0]['slug'], $slugs );
|
||||
$this->assertCount( count( $all ) - 1, $slugs );
|
||||
}
|
||||
|
||||
public function test_update_health_sets_correct_status(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
$all = PRV_Model_Registry::get_all();
|
||||
$slug0 = $all[0]['slug'];
|
||||
$slug1 = $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 );
|
||||
|
||||
foreach ( PRV_Model_Registry::get_all() as $m ) {
|
||||
if ( $m['slug'] === $slug0 ) {
|
||||
$this->assertSame( 'healthy', $m['health_status'] );
|
||||
$this->assertSame( 5, $m['health_probed'] );
|
||||
$this->assertSame( $run_id, $m['health_run_id'] );
|
||||
}
|
||||
if ( $slug1 && $m['slug'] === $slug1 ) {
|
||||
$this->assertSame( 'retired', $m['health_status'] );
|
||||
$this->assertSame( 3, $m['health_errors'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
158
tests/unit/ProbeRunnerTest.php
Normal file
158
tests/unit/ProbeRunnerTest.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Probe_Runner: budget-cap abort, run-lock, model health.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class PRV_Mock_Ledger extends PRV_Cost_Ledger {
|
||||
public int $afford_count = 0;
|
||||
public int $no_afford_count = 0;
|
||||
public int $exhaust_after = PHP_INT_MAX;
|
||||
|
||||
public function can_afford( float $estimated ): bool {
|
||||
$total = $this->afford_count + $this->no_afford_count;
|
||||
if ( $total >= $this->exhaust_after ) {
|
||||
$this->no_afford_count++;
|
||||
return false;
|
||||
}
|
||||
$this->afford_count++;
|
||||
return true;
|
||||
}
|
||||
public function update_row_cost( int $row_id, float $cost ): bool { return true; }
|
||||
public function get_month_to_date_usd(): float { return 0.0; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers PRV_Probe_Runner
|
||||
*/
|
||||
class ProbeRunnerTest extends TestCase {
|
||||
|
||||
private array $sonar_row;
|
||||
private string $cited_body;
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
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', '' );
|
||||
}
|
||||
$this->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 ];
|
||||
$this->cited_body = json_encode( [ 'choices' => [ [ 'message' => [ 'content' => 'BPC-157 info.' ] ] ], 'citations' => [ 'https://peptiderepo.com/bpc-157/', 'https://examine.com' ], 'usage' => [ 'total_tokens' => 100 ] ] );
|
||||
}
|
||||
|
||||
private function setupRun( array $model_rows, array $peptides = null, array $intents = null ): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
PRV_Config_Version::maybe_seed_initial_version();
|
||||
$GLOBALS['prv_test_state']['options']['prv_models'] = $model_rows;
|
||||
$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION;
|
||||
$GLOBALS['prv_test_state']['options']['prv_peptides'] = $peptides ?? [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ] ];
|
||||
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = $intents ?? [ 'what is {peptide}' ];
|
||||
}
|
||||
|
||||
public function test_basic_run_1x1x1(): void {
|
||||
$this->setupRun( [ $this->sonar_row ] );
|
||||
$GLOBALS['prv_test_state']['remote_posts'] = [ [ 'response' => [ 'code' => 200 ], 'body' => $this->cited_body ] ];
|
||||
|
||||
$ledger = new PRV_Mock_Ledger();
|
||||
$counts = ( new PRV_Probe_Runner( $ledger ) )->run();
|
||||
|
||||
$this->assertSame( 1, $counts['probed'] );
|
||||
$this->assertSame( 0, $counts['skipped_budget'] );
|
||||
$this->assertSame( 0, $counts['skipped_error'] );
|
||||
$this->assertSame( 1, $ledger->afford_count );
|
||||
$this->assertNotEmpty( $counts['run_id'] );
|
||||
$this->assertFalse( $counts['truncated'] );
|
||||
}
|
||||
|
||||
public function test_budget_cap_abort(): void {
|
||||
$this->setupRun(
|
||||
[ $this->sonar_row ],
|
||||
[ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ], [ 'slug' => 'tb-500', 'label' => 'TB-500' ], [ 'slug' => 'mk-677', 'label' => 'MK-677' ] ]
|
||||
);
|
||||
$GLOBALS['prv_test_state']['remote_posts'] = array_fill( 0, 3, [ 'response' => [ 'code' => 200 ], 'body' => $this->cited_body ] );
|
||||
|
||||
$exh = new PRV_Mock_Ledger();
|
||||
$exh->exhaust_after = 1;
|
||||
$counts = ( new PRV_Probe_Runner( $exh ) )->run();
|
||||
|
||||
$this->assertSame( 1, $counts['probed'] );
|
||||
$this->assertGreaterThanOrEqual( 2, $counts['skipped_budget'] );
|
||||
$this->assertSame( 0, $counts['skipped_error'] );
|
||||
$this->assertTrue( $counts['truncated'] );
|
||||
}
|
||||
|
||||
public function test_http_error_counts_as_skipped_error(): void {
|
||||
$this->setupRun( [ $this->sonar_row ] );
|
||||
$GLOBALS['prv_test_state']['remote_posts'] = array_fill( 0, 3, [ 'response' => [ 'code' => 500 ], 'body' => 'Error' ] );
|
||||
|
||||
$counts = ( new PRV_Probe_Runner( new PRV_Mock_Ledger() ) )->run();
|
||||
$this->assertSame( 0, $counts['probed'] );
|
||||
$this->assertGreaterThanOrEqual( 1, $counts['skipped_error'] );
|
||||
}
|
||||
|
||||
public function test_wp_error_counts_as_skipped_error(): void {
|
||||
$this->setupRun( [ $this->sonar_row ] );
|
||||
$wp_error = new WP_Error( 'http_request_failed', 'cURL error 28' );
|
||||
$GLOBALS['prv_test_state']['remote_posts'] = [ $wp_error, $wp_error, $wp_error ];
|
||||
|
||||
$counts = ( new PRV_Probe_Runner( new PRV_Mock_Ledger() ) )->run();
|
||||
$this->assertSame( 0, $counts['probed'] );
|
||||
$this->assertGreaterThanOrEqual( 1, $counts['skipped_error'] );
|
||||
}
|
||||
|
||||
public function test_run_returns_all_required_keys(): void {
|
||||
$this->setupRun( [ $this->sonar_row ] );
|
||||
$GLOBALS['prv_test_state']['remote_posts'] = [ [ 'response' => [ 'code' => 200 ], 'body' => $this->cited_body ] ];
|
||||
|
||||
$counts = ( new PRV_Probe_Runner( new PRV_Mock_Ledger() ) )->run();
|
||||
foreach ( [ 'probed', 'skipped_budget', 'skipped_error', 'truncated', 'run_id' ] as $key ) {
|
||||
$this->assertArrayHasKey( $key, $counts );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_run_lock_refused_when_locked(): void {
|
||||
$this->setupRun( [ $this->sonar_row ] );
|
||||
PRV_Run_Lock::acquire();
|
||||
|
||||
$ledger = new PRV_Mock_Ledger();
|
||||
$counts = ( new PRV_Probe_Runner( $ledger ) )->run();
|
||||
|
||||
$this->assertSame( 0, $counts['probed'] );
|
||||
$this->assertSame( -1, $counts['skipped_error'] );
|
||||
$this->assertSame( 0, $ledger->afford_count );
|
||||
PRV_Run_Lock::release();
|
||||
}
|
||||
|
||||
public function test_health_updated_after_successful_run(): void {
|
||||
$this->setupRun( [ $this->sonar_row ] );
|
||||
$GLOBALS['prv_test_state']['remote_posts'] = [ [ 'response' => [ 'code' => 200 ], 'body' => $this->cited_body ] ];
|
||||
|
||||
( new PRV_Probe_Runner( new PRV_Mock_Ledger() ) )->run();
|
||||
|
||||
foreach ( PRV_Model_Registry::get_all() as $m ) {
|
||||
if ( 'perplexity/sonar' === $m['slug'] ) {
|
||||
$this->assertSame( 'healthy', $m['health_status'] );
|
||||
$this->assertSame( 1, $m['health_probed'] );
|
||||
$this->assertSame( 0, $m['health_errors'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_lock_released_after_successful_run(): void {
|
||||
$this->setupRun( [ $this->sonar_row ] );
|
||||
$GLOBALS['prv_test_state']['remote_posts'] = [ [ 'response' => [ 'code' => 200 ], 'body' => $this->cited_body ] ];
|
||||
|
||||
( new PRV_Probe_Runner( new PRV_Mock_Ledger() ) )->run();
|
||||
$this->assertFalse( PRV_Run_Lock::is_locked() );
|
||||
}
|
||||
}
|
||||
113
tests/unit/ProjectedCostTest.php
Normal file
113
tests/unit/ProjectedCostTest.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for projected cost calculation and budget cap logic.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class PRV_Exhausting_Ledger 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; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers PRV_Config
|
||||
*/
|
||||
class ProjectedCostTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
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', '' );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_projected_cost_reflects_enabled_models_x_peptides_x_intents(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
$GLOBALS['prv_test_state']['options']['prv_models'] = [
|
||||
[ 'id' => '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();
|
||||
|
||||
$this->assertSame( 8, $cost['probe_count'], '2 models x 2 peptides x 2 intents = 8' );
|
||||
$this->assertGreaterThan( 0.0, $cost['per_run_usd'] );
|
||||
$this->assertGreaterThan( 0.0, $cost['per_month_usd'] );
|
||||
$this->assertGreaterThanOrEqual( $cost['per_run_usd'], $cost['per_month_usd'] );
|
||||
$this->assertFalse( $cost['over_cap'] );
|
||||
}
|
||||
|
||||
public function test_over_cap_flag_fires_when_projected_exceeds_cap(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
$big = [];
|
||||
for ( $i = 0; $i < 100; $i++ ) {
|
||||
$big[] = [ '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;
|
||||
$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 = PRV_Config::get_projected_cost();
|
||||
$this->assertTrue( $cost['over_cap'] );
|
||||
}
|
||||
|
||||
public function test_disabled_models_excluded_from_probe_count(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
$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 = PRV_Config::get_projected_cost();
|
||||
$this->assertSame( 1, $cost['probe_count'] );
|
||||
}
|
||||
|
||||
public function test_truncation_flag_set_when_budget_hit_mid_run(): void {
|
||||
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}' ];
|
||||
|
||||
$cited_body = json_encode( [ 'choices' => [ [ 'message' => [ 'content' => 'test' ] ] ], 'citations' => [ 'https://peptiderepo.com/bpc-157/' ], 'usage' => [ 'total_tokens' => 50 ] ] );
|
||||
$GLOBALS['prv_test_state']['remote_posts'] = array_fill( 0, 3, [ 'response' => [ 'code' => 200 ], 'body' => $cited_body ] );
|
||||
|
||||
$counts = ( new PRV_Probe_Runner( new PRV_Exhausting_Ledger() ) )->run();
|
||||
$this->assertTrue( $counts['truncated'] );
|
||||
$this->assertSame( 1, (int) get_option( 'prv_last_run_truncated', 0 ) );
|
||||
}
|
||||
}
|
||||
91
tests/unit/ProviderParsingTest.php
Normal file
91
tests/unit/ProviderParsingTest.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for provider response parsing (Perplexity + OpenRouter).
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers PRV_Perplexity_Provider
|
||||
* @covers PRV_OpenRouter_Provider
|
||||
*/
|
||||
class ProviderParsingTest extends TestCase {
|
||||
|
||||
private PRV_Perplexity_Provider $perplexity;
|
||||
private PRV_OpenRouter_Provider $openrouter;
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
$detector = new PRV_Citation_Detector();
|
||||
$this->perplexity = new PRV_Perplexity_Provider( null, $detector );
|
||||
$this->openrouter = new PRV_OpenRouter_Provider( 'openai/gpt-4o-search-preview', null, $detector );
|
||||
}
|
||||
|
||||
public function test_perplexity_cited_via_citations_array(): void {
|
||||
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'BPC-157 is a peptide.' ] ] ], 'citations' => [ 'https://peptiderepo.com/bpc-157/', 'https://examine.com/bpc-157' ], 'usage' => [ 'total_tokens' => 500 ] ];
|
||||
$result = $this->perplexity->parse_response( $data );
|
||||
|
||||
$this->assertTrue( $result->is_cited() );
|
||||
$this->assertSame( 1, $result->get_our_position() );
|
||||
$this->assertCount( 2, $result->get_source_domains() );
|
||||
$this->assertSame( 500 * 0.000001, $result->get_cost_usd() );
|
||||
}
|
||||
|
||||
public function test_perplexity_not_cited_empty_citations(): void {
|
||||
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'Info here.' ] ] ], 'citations' => [], 'usage' => [ 'total_tokens' => 200 ] ];
|
||||
$result = $this->perplexity->parse_response( $data );
|
||||
|
||||
$this->assertFalse( $result->is_cited() );
|
||||
$this->assertNull( $result->get_our_position() );
|
||||
}
|
||||
|
||||
public function test_perplexity_excerpt_truncated_to_500_chars(): void {
|
||||
$data = [ 'choices' => [ [ 'message' => [ 'content' => str_repeat( 'a', 600 ) ] ] ], 'citations' => [] ];
|
||||
$result = $this->perplexity->parse_response( $data );
|
||||
$this->assertSame( 500, mb_strlen( $result->get_raw_excerpt() ) );
|
||||
}
|
||||
|
||||
public function test_perplexity_object_style_citations(): void {
|
||||
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'answer' ] ] ], 'citations' => [ [ 'url' => 'https://peptiderepo.com/mk-677/' ], [ 'url' => 'https://examine.com/mk677' ] ] ];
|
||||
$result = $this->perplexity->parse_response( $data );
|
||||
|
||||
$this->assertTrue( $result->is_cited() );
|
||||
$this->assertSame( 1, $result->get_our_position() );
|
||||
}
|
||||
|
||||
public function test_perplexity_fallback_cost_when_no_usage(): void {
|
||||
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'answer' ] ] ], 'citations' => [] ];
|
||||
$result = $this->perplexity->parse_response( $data );
|
||||
$this->assertSame( PRV_Perplexity_Provider::ESTIMATED_COST_PER_PROBE, $result->get_cost_usd() );
|
||||
}
|
||||
|
||||
public function test_openrouter_annotations_path(): void {
|
||||
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'BPC-157 reconstitution.', 'annotations' => [ [ 'url_citation' => [ 'url' => 'https://peptiderepo.com/bpc-157-reconstitution/' ] ], [ 'url_citation' => [ 'url' => 'https://examine.com/bpc-157' ] ] ] ] ] ], 'usage' => [ 'total_tokens' => 300 ] ];
|
||||
$result = $this->openrouter->parse_response( $data );
|
||||
|
||||
$this->assertTrue( $result->is_cited() );
|
||||
$this->assertSame( 1, $result->get_our_position() );
|
||||
}
|
||||
|
||||
public function test_openrouter_regex_fallback(): void {
|
||||
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'See https://peptiderepo.com/tb-500/ and https://examine.com/tb-500 for details.' ] ] ], 'usage' => [ 'total_tokens' => 150 ] ];
|
||||
$result = $this->openrouter->parse_response( $data );
|
||||
$this->assertTrue( $result->is_cited() );
|
||||
}
|
||||
|
||||
public function test_openrouter_not_cited(): void {
|
||||
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'Some response without our domain.' ] ] ] ];
|
||||
$result = $this->openrouter->parse_response( $data );
|
||||
$this->assertFalse( $result->is_cited() );
|
||||
}
|
||||
|
||||
public function test_openrouter_cost_from_token_usage(): void {
|
||||
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'test' ] ] ], 'usage' => [ 'total_tokens' => 1000 ] ];
|
||||
$result = $this->openrouter->parse_response( $data );
|
||||
$this->assertSame( 1000 * 0.000002, $result->get_cost_usd() );
|
||||
}
|
||||
}
|
||||
89
tests/unit/RunLockTest.php
Normal file
89
tests/unit/RunLockTest.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Run_Lock: acquire/release/is_locked concurrency guard.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers PRV_Run_Lock
|
||||
*/
|
||||
class RunLockTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
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', '' );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_acquire_returns_true_when_free(): void {
|
||||
$this->assertTrue( PRV_Run_Lock::acquire() );
|
||||
PRV_Run_Lock::release();
|
||||
}
|
||||
|
||||
public function test_is_locked_true_after_acquire(): void {
|
||||
PRV_Run_Lock::acquire();
|
||||
$this->assertTrue( PRV_Run_Lock::is_locked() );
|
||||
PRV_Run_Lock::release();
|
||||
}
|
||||
|
||||
public function test_second_acquire_returns_false(): void {
|
||||
PRV_Run_Lock::acquire();
|
||||
$this->assertFalse( PRV_Run_Lock::acquire() );
|
||||
PRV_Run_Lock::release();
|
||||
}
|
||||
|
||||
public function test_release_clears_lock(): void {
|
||||
PRV_Run_Lock::acquire();
|
||||
PRV_Run_Lock::release();
|
||||
$this->assertFalse( PRV_Run_Lock::is_locked() );
|
||||
}
|
||||
|
||||
public function test_acquire_succeeds_again_after_release(): void {
|
||||
PRV_Run_Lock::acquire();
|
||||
PRV_Run_Lock::release();
|
||||
$this->assertTrue( PRV_Run_Lock::acquire() );
|
||||
PRV_Run_Lock::release();
|
||||
}
|
||||
|
||||
public function test_locked_since_returns_null_when_not_locked(): void {
|
||||
$this->assertNull( PRV_Run_Lock::locked_since() );
|
||||
}
|
||||
|
||||
public function test_locked_since_non_null_when_locked(): void {
|
||||
PRV_Run_Lock::acquire();
|
||||
$since = PRV_Run_Lock::locked_since();
|
||||
$this->assertNotNull( $since );
|
||||
$this->assertGreaterThanOrEqual( 0, $since );
|
||||
PRV_Run_Lock::release();
|
||||
}
|
||||
|
||||
public function test_runner_refuses_when_locked(): void {
|
||||
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}' ];
|
||||
|
||||
PRV_Run_Lock::acquire();
|
||||
$counts = ( new PRV_Probe_Runner( new PRV_Cost_Ledger() ) )->run();
|
||||
|
||||
$this->assertSame( 0, $counts['probed'] );
|
||||
$this->assertSame( -1, $counts['skipped_error'] );
|
||||
PRV_Run_Lock::release();
|
||||
}
|
||||
|
||||
public function test_lock_released_after_runner_completes(): void {
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
$this->assertFalse( PRV_Run_Lock::is_locked() );
|
||||
}
|
||||
}
|
||||
57
tests/unit/ScoreCalcTest.php
Normal file
57
tests/unit/ScoreCalcTest.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for the visibility score formula in PRV_Ai_Visibility_Collector.
|
||||
*
|
||||
* Formula: score = round((cited/total + position_sum/total) / 2, 4)
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers PRV_Ai_Visibility_Collector
|
||||
*/
|
||||
class ScoreCalcTest extends TestCase {
|
||||
|
||||
private PRV_Ai_Visibility_Collector $collector;
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
$this->collector = new PRV_Ai_Visibility_Collector( null );
|
||||
}
|
||||
|
||||
public function test_zero_total_returns_zero(): void {
|
||||
$this->assertSame( 0.0, $this->collector->compute_score( 0, 0, 0.0 ) );
|
||||
}
|
||||
|
||||
public function test_all_cited_position_1_returns_1(): void {
|
||||
$this->assertSame( 1.0, $this->collector->compute_score( 10, 10, 10.0 ) );
|
||||
}
|
||||
|
||||
public function test_none_cited_returns_zero(): void {
|
||||
$this->assertSame( 0.0, $this->collector->compute_score( 0, 10, 0.0 ) );
|
||||
}
|
||||
|
||||
public function test_half_cited_at_position_2(): void {
|
||||
// base=0.5, bonus=0.25, score=0.375
|
||||
$this->assertSame( 0.375, $this->collector->compute_score( 5, 10, 2.5 ) );
|
||||
}
|
||||
|
||||
public function test_single_probe_cited_at_position_3(): void {
|
||||
// base=1.0, bonus=0.3333, score=0.6667
|
||||
$this->assertSame( 0.6667, $this->collector->compute_score( 1, 1, 1.0 / 3.0 ) );
|
||||
}
|
||||
|
||||
public function test_score_bounded_within_0_and_1(): void {
|
||||
$this->assertLessThanOrEqual( 1.0, $this->collector->compute_score( 1, 1, 1.0 ) );
|
||||
$this->assertGreaterThanOrEqual( 0.0, $this->collector->compute_score( 0, 10, 0.0 ) );
|
||||
}
|
||||
|
||||
public function test_four_decimal_precision(): void {
|
||||
$expected = round( ( 3.0 / 7.0 + 1.5 / 7.0 ) / 2.0, 4 );
|
||||
$this->assertSame( $expected, $this->collector->compute_score( 3, 7, 1.5 ) );
|
||||
}
|
||||
}
|
||||
82
tests/unit/UninstallTest.php
Normal file
82
tests/unit/UninstallTest.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for uninstall purge: all prv_ options deleted, lock cleared.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers PRV_Activator
|
||||
* @covers PRV_Deactivator
|
||||
*/
|
||||
class UninstallTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
prv_test_reset();
|
||||
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', '' );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_uninstall_purges_all_prv_options(): void {
|
||||
// Populate all known prv_ options.
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
PRV_Config_Version::maybe_seed_initial_version();
|
||||
update_option( 'prv_monthly_budget_usd', 5.0 );
|
||||
update_option( 'prv_peptides', [] );
|
||||
update_option( 'prv_prompt_intents', [] );
|
||||
update_option( PRV_Config::CADENCE_KEY, 'weekly' );
|
||||
update_option( 'prv_api_key_status', 'ok' );
|
||||
update_option( 'prv_api_key_last_check', '2026-06-14 12:00:00' );
|
||||
update_option( 'prv_last_run_at', '2026-06-14 12:00:00' );
|
||||
update_option( 'prv_last_run_counts', [] );
|
||||
update_option( 'prv_last_run_truncated', 0 );
|
||||
update_option( 'prv_last_run_truncated_at', '' );
|
||||
update_option( 'prv_provider_key_enc', 'dummy-encrypted-ciphertext-value' );
|
||||
update_option( 'prv_schema_version', PRV_SCHEMA_VERSION );
|
||||
$GLOBALS['prv_test_state']['transients'][ PRV_Run_Lock::LOCK_KEY ] = time();
|
||||
$GLOBALS['prv_test_state']['cron_events'][ PRV_CRON_HOOK ] = [ 'timestamp' => time() + 3600, 'schedule' => 'weekly' ];
|
||||
|
||||
// Verify pre-uninstall state.
|
||||
$this->assertNotFalse( get_option( 'prv_models' ) );
|
||||
$this->assertNotFalse( get_option( 'prv_monthly_budget_usd' ) );
|
||||
$this->assertNotFalse( get_option( 'prv_api_key_status' ) );
|
||||
$this->assertNotFalse( get_option( 'prv_active_config_version' ) );
|
||||
$this->assertNotFalse( get_transient( PRV_Run_Lock::LOCK_KEY ) );
|
||||
$this->assertTrue( PRV_Cron::is_scheduled() );
|
||||
|
||||
// Simulate uninstall: delete all prv_ options.
|
||||
$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 ] );
|
||||
}
|
||||
delete_transient( PRV_Run_Lock::LOCK_KEY );
|
||||
wp_clear_scheduled_hook( PRV_CRON_HOOK );
|
||||
|
||||
// Verify all prv_ options are gone.
|
||||
$this->assertFalse( get_option( 'prv_models' ) );
|
||||
$this->assertFalse( get_option( 'prv_monthly_budget_usd' ) );
|
||||
$this->assertFalse( get_option( 'prv_api_key_status' ) );
|
||||
$this->assertFalse( get_option( 'prv_active_config_version' ) );
|
||||
$this->assertFalse( get_option( 'prv_config_versions' ) );
|
||||
$this->assertFalse( get_option( 'prv_models_schema_version' ) );
|
||||
$this->assertFalse( get_option( 'prv_schema_version' ) );
|
||||
$this->assertFalse( get_option( 'prv_provider_key_enc' ) );
|
||||
$this->assertFalse( get_option( 'prv_last_run_truncated' ) );
|
||||
$this->assertFalse( get_transient( PRV_Run_Lock::LOCK_KEY ) );
|
||||
$this->assertFalse( PRV_Cron::is_scheduled() );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user