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:
peptiderepo
2026-06-16 15:25:22 +00:00
parent 1d8f024380
commit 01a9356c16
26 changed files with 2459 additions and 182 deletions

View 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' );
}
}

View 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' );
}
}

View 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' );
}
}

View 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)' );
}
}

View 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' );
}
}

View 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' );
}
}

View 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
View 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() );
}
}

View 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
View 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()' );
}
}

View 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'] );
}
}
}
}

View 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() );
}
}

View 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 ) );
}
}

View 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() );
}
}

View 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() );
}
}

View 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 ) );
}
}

View 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() );
}
}